Auto-scroll when dragging to create range selection in staging view (#5855)

The staging view (and custom patch building view) already has the
capability to create a range selection by dragging with the mouse;
however, a longer range couldn't be selected this way because the
dragging stopped at the view edge. Add auto-scrolling so that the view
scrolls as the mouse reaches the view edge; slowly on the innermost edge
row, faster on the outermost row and beyond. Scrolling starts after a
short delay so that a drag merely passing near the edge doesn't scroll.
This commit is contained in:
Stefan Haller 2026-07-31 08:32:25 +02:00 committed by GitHub
commit 10bac9dbf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 500 additions and 20 deletions

View file

@ -0,0 +1,154 @@
package helpers
import (
"time"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
const (
dragAutoscrollInitialDelay = 300 * time.Millisecond
dragAutoscrollSlowInterval = 250 * time.Millisecond
dragAutoscrollFastInterval = 100 * time.Millisecond
dragAutoscrollVeryFastInterval = 50 * time.Millisecond
)
// All state is UI-thread-owned. Timer goroutines only enqueue tick back onto
// the UI thread, where generation changes and scroll callbacks are serialized
// with mouse handlers and focus changes.
type DragAutoscroller struct {
c *HelperCommon
context types.Context
canScroll func(direction int) bool
onScroll func(viewIndex int) bool
// Incremented whenever the scroll direction changes or the autoscroller
// is canceled. A scheduled tick carries the generation it was created
// for, so stale ticks can be told apart from the one that is current.
generation uint64
direction int
interval time.Duration
// Last known pointer position relative to the viewport; used by ticks to
// compute which line ends up under the pointer after scrolling.
pointerViewportY int
}
func NewDragAutoscroller(
c *HelperCommon,
context types.Context,
canScroll func(direction int) bool,
onScroll func(viewIndex int) bool,
) *DragAutoscroller {
return &DragAutoscroller{
c: c,
context: context,
canScroll: canScroll,
onScroll: onScroll,
}
}
// Update is called with the pointer position of every drag event. Entering a
// scroll zone arms a timer (with an initial delay, so that merely passing
// through the zone doesn't scroll); once armed, scrolling continues on its
// own until the pointer leaves the zone, the drag ends, or a callback stops
// it.
func (self *DragAutoscroller) Update(pointerViewportY int) {
_, viewportHeight := self.context.GetViewTrait().ViewPortYBounds()
direction, interval := dragAutoscrollZone(viewportHeight, pointerViewportY)
if direction != 0 && self.canScroll != nil && !self.canScroll(direction) {
direction = 0
interval = 0
}
self.pointerViewportY = pointerViewportY
generation, schedule := self.updateState(direction, interval)
if schedule {
self.schedule(generation, dragAutoscrollInitialDelay)
}
}
func (self *DragAutoscroller) Direction() int {
return self.direction
}
func (self *DragAutoscroller) updateState(direction int, interval time.Duration) (uint64, bool) {
if direction == self.direction {
self.interval = interval
return self.generation, false
}
self.generation++
self.direction = direction
self.interval = interval
return self.generation, direction != 0
}
func (self *DragAutoscroller) Cancel() {
self.generation++
self.direction = 0
self.interval = 0
}
func (self *DragAutoscroller) schedule(generation uint64, delay time.Duration) {
time.AfterFunc(delay, func() {
self.c.OnUIThreadBackground(func() error {
self.tick(generation)
return nil
})
})
}
func (self *DragAutoscroller) tick(generation uint64) {
if generation != self.generation {
return
}
if self.direction == 0 ||
self.canScroll != nil && !self.canScroll(self.direction) {
self.Cancel()
return
}
view := self.context.GetViewTrait()
oldOriginY, _ := view.ViewPortYBounds()
if self.direction < 0 {
view.ScrollUp(1)
} else {
view.ScrollDown(1)
}
newOriginY, _ := view.ViewPortYBounds()
if newOriginY == oldOriginY {
self.Cancel()
return
}
if !self.onScroll(newOriginY + self.pointerViewportY) {
self.Cancel()
return
}
self.schedule(generation, self.interval)
}
// dragAutoscrollZone returns the scroll direction and tick interval for a
// pointer position: anything beyond the view scrolls very fast, the outermost
// viewport row scrolls fast, the row just inside it scrolls slowly, and anything
// further inside doesn't scroll at all.
func dragAutoscrollZone(viewportHeight int, pointerViewportY int) (int, time.Duration) {
switch {
case pointerViewportY < 0:
return -1, dragAutoscrollVeryFastInterval
case pointerViewportY == 0:
return -1, dragAutoscrollFastInterval
case pointerViewportY == 1:
return -1, dragAutoscrollSlowInterval
case pointerViewportY > viewportHeight-1:
return 1, dragAutoscrollVeryFastInterval
case pointerViewportY == viewportHeight-1:
return 1, dragAutoscrollFastInterval
case pointerViewportY == viewportHeight-2:
return 1, dragAutoscrollSlowInterval
default:
return 0, 0
}
}

View file

@ -0,0 +1,62 @@
package helpers
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestDragAutoscrollZone(t *testing.T) {
testCases := []struct {
name string
pointerViewportY int
expectedDirection int
expectedInterval time.Duration
}{
{name: "above view", pointerViewportY: -1, expectedDirection: -1, expectedInterval: dragAutoscrollVeryFastInterval},
{name: "top outer row", pointerViewportY: 0, expectedDirection: -1, expectedInterval: dragAutoscrollFastInterval},
{name: "top inner row", pointerViewportY: 1, expectedDirection: -1, expectedInterval: dragAutoscrollSlowInterval},
{name: "middle", pointerViewportY: 5},
{name: "bottom inner row", pointerViewportY: 8, expectedDirection: 1, expectedInterval: dragAutoscrollSlowInterval},
{name: "bottom outer row", pointerViewportY: 9, expectedDirection: 1, expectedInterval: dragAutoscrollFastInterval},
{name: "below view", pointerViewportY: 10, expectedDirection: 1, expectedInterval: dragAutoscrollVeryFastInterval},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
direction, interval := dragAutoscrollZone(10, testCase.pointerViewportY)
assert.Equal(t, testCase.expectedDirection, direction)
assert.Equal(t, testCase.expectedInterval, interval)
})
}
}
func TestDragAutoscrollerDoesNotRestartWhenMovingToOuterEdge(t *testing.T) {
self := &DragAutoscroller{
generation: 1,
direction: 1,
interval: dragAutoscrollSlowInterval,
}
generation, schedule := self.updateState(1, dragAutoscrollFastInterval)
assert.Equal(t, uint64(1), generation)
assert.False(t, schedule)
assert.Equal(t, dragAutoscrollFastInterval, self.interval)
}
func TestStaleDragAutoscrollTickDoesNotCancelCurrentGeneration(t *testing.T) {
self := &DragAutoscroller{
generation: 4,
direction: 1,
interval: dragAutoscrollFastInterval,
}
self.tick(2)
assert.Equal(t, uint64(4), self.generation)
assert.Equal(t, 1, self.direction)
assert.Equal(t, dragAutoscrollFastInterval, self.interval)
}

View file

@ -4,6 +4,7 @@ import (
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
@ -19,18 +20,27 @@ func NewPatchExplorerControllerFactory(c *ControllerCommon) *PatchExplorerContro
}
func (self *PatchExplorerControllerFactory) Create(context types.IPatchExplorerContext) *PatchExplorerController {
return &PatchExplorerController{
controller := &PatchExplorerController{
baseController: baseController{},
c: self.c,
context: context,
}
controller.dragAutoscroller = helpers.NewDragAutoscroller(
self.c.HelperCommon,
context,
controller.canDragAutoscroll,
controller.handleDragAutoscroll,
)
return controller
}
type PatchExplorerController struct {
baseController
c *ControllerCommon
context types.IPatchExplorerContext
context types.IPatchExplorerContext
dragAutoscroller *helpers.DragAutoscroller
draggingWithMouse bool
}
func (self *PatchExplorerController) Context() types.Context {
@ -153,10 +163,74 @@ func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsO
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModMotion,
Handler: func(gocui.ViewMouseBindingOpts) error {
return self.withRenderAndFocus(self.HandleMouseDrag)()
},
Handler: self.handleMouseDrag,
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseRelease,
Handler: func(gocui.ViewMouseBindingOpts) error { return self.handleDragRelease() },
},
}
}
func (self *PatchExplorerController) handleMouseDrag(opts gocui.ViewMouseBindingOpts) error {
if err := self.withLock(func() error {
self.context.GetState().DragSelectLine(opts.Y)
self.renderDragSelection()
return nil
})(); err != nil {
return err
}
self.draggingWithMouse = true
originY, _ := self.context.GetViewTrait().ViewPortYBounds()
self.dragAutoscroller.Update(opts.Y - originY)
return nil
}
func (self *PatchExplorerController) canDragAutoscroll(int) bool {
state := self.context.GetState()
return state != nil && state.SelectingRange()
}
func (self *PatchExplorerController) handleDragAutoscroll(viewIndex int) bool {
if !self.canDragAutoscroll(0) {
return false
}
if err := self.withLock(func() error {
self.context.GetState().DragSelectLine(viewIndex)
self.renderDragSelection()
return nil
})(); err != nil {
return false
}
return true
}
func (self *PatchExplorerController) renderDragSelection() {
view := self.context.GetView()
state := self.context.GetState()
originY := view.OriginY()
startIndex, _ := state.SelectedViewRange()
view.SetRangeSelectStart(startIndex)
view.SetCursorY(state.GetSelectedViewLineIdx() - originY)
self.context.Render()
}
func (self *PatchExplorerController) handleDragRelease() error {
self.draggingWithMouse = false
self.dragAutoscroller.Cancel()
return nil
}
func (self *PatchExplorerController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(types.OnFocusLostOpts) {
self.dragAutoscroller.Cancel()
if self.draggingWithMouse {
self.draggingWithMouse = false
self.c.GocuiGui().CancelMouseCapture()
}
}
}
@ -266,12 +340,6 @@ func (self *PatchExplorerController) HandleMouseDown() error {
return nil
}
func (self *PatchExplorerController) HandleMouseDrag() error {
self.context.GetState().DragSelectLine(self.context.GetViewTrait().SelectedLineIdx())
return nil
}
func (self *PatchExplorerController) CopySelectedToClipboard() error {
selected := self.context.GetState().PlainRenderSelected()

View file

@ -53,13 +53,33 @@ func (self *GuiDriver) PressKeysRapidly(keyStrs ...string) {
func (self *GuiDriver) Click(x, y int) {
self.CheckAllToastsAcknowledged()
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
self.replayMouseEvent(x, y, tcell.ButtonNone)
}
func (self *GuiDriver) ClickAndHold(x, y int) {
self.CheckAllToastsAcknowledged()
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
}
// MouseMove reports the mouse at a new position with the left button still
// held down, i.e. a drag movement. (No test needs pointer motion without a
// button held, so that variant doesn't exist.)
func (self *GuiDriver) MouseMove(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
}
func (self *GuiDriver) MouseRelease(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonNone)
}
func (self *GuiDriver) OnUIThreadAndWait(f func()) {
_ = self.gui.g.OnUIThreadAndWait(func() error { f(); return nil })
}
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) {
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0),
0,
))
self.waitTillIdle()
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, tcell.ButtonNone, 0),
tcell.NewEventMouse(x, y, buttons, 0),
0,
))
self.waitTillIdle()

View file

@ -1,9 +1,13 @@
package components
import (
"time"
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
)
const eventuallyTimeout = 2 * time.Second
type assertionHelper struct {
gui integrationTypes.GuiDriver
}
@ -24,6 +28,21 @@ func (self *assertionHelper) assertWithRetries(test func() (bool, string)) {
}
}
func (self *assertionHelper) assertEventually(test func() (bool, string)) {
deadline := time.Now().Add(eventuallyTimeout)
for {
ok, message := test()
if ok {
return
}
if time.Now().After(deadline) {
self.fail(message)
return
}
time.Sleep(10 * time.Millisecond)
}
}
func (self *assertionHelper) fail(message string) {
self.gui.Fail(message)
}

View file

@ -13,6 +13,8 @@ type TestDriver struct {
gui integrationTypes.GuiDriver
keys config.KeybindingConfig
inputDelay int
mouseX int
mouseY int
*assertionHelper
shell *Shell
}
@ -58,6 +60,30 @@ func (self *TestDriver) click(x, y int) {
self.Wait(self.inputDelay)
}
func (self *TestDriver) clickAndHold(x, y int) {
self.SetCaption(fmt.Sprintf("Clicking and holding %d, %d", x, y))
self.mouseX, self.mouseY = x, y
self.gui.ClickAndHold(x, y)
self.Wait(self.inputDelay)
}
func (self *TestDriver) mouseMove(x, y int) {
self.SetCaption(fmt.Sprintf("Moving mouse to %d, %d", x, y))
self.mouseX, self.mouseY = x, y
self.gui.MouseMove(x, y)
self.Wait(self.inputDelay)
}
func (self *TestDriver) repeatMouseMove() {
self.mouseMove(self.mouseX, self.mouseY)
}
func (self *TestDriver) mouseRelease() {
self.SetCaption(fmt.Sprintf("Releasing mouse at %d, %d", self.mouseX, self.mouseY))
self.gui.MouseRelease(self.mouseX, self.mouseY)
self.Wait(self.inputDelay)
}
// Should only be used in specific cases where you're doing something weird!
// E.g. invoking a global keybinding from within a popup.
// You probably shouldn't use this function, and should instead go through a view like t.Views().Commit().Focus().Press(...)

View file

@ -19,9 +19,12 @@ type coordinate struct {
}
type fakeGuiDriver struct {
failureMessage string
pressedKeys []string
clickedCoordinates []coordinate
failureMessage string
pressedKeys []string
clickedCoordinates []coordinate
heldCoordinates []coordinate
movedCoordinates []coordinate
releasedCoordinates []coordinate
}
var _ integrationTypes.GuiDriver = &fakeGuiDriver{}
@ -38,6 +41,22 @@ func (self *fakeGuiDriver) Click(x, y int) {
self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) ClickAndHold(x, y int) {
self.heldCoordinates = append(self.heldCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) MouseMove(x, y int) {
self.movedCoordinates = append(self.movedCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) MouseRelease(x, y int) {
self.releasedCoordinates = append(self.releasedCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) OnUIThreadAndWait(f func()) {
f()
}
func (self *fakeGuiDriver) FocusIn() {
}
@ -123,12 +142,19 @@ func TestSuccess(t *testing.T) {
t.press("b")
t.click(0, 1)
t.click(2, 3)
t.clickAndHold(0, 1)
t.mouseMove(2, 3)
t.repeatMouseMove()
t.mouseRelease()
},
})
driver := &fakeGuiDriver{}
test.Run(driver)
assert.EqualValues(t, []string{"a", "b"}, driver.pressedKeys)
assert.EqualValues(t, []coordinate{{0, 1}, {2, 3}}, driver.clickedCoordinates)
assert.EqualValues(t, []coordinate{{0, 1}}, driver.heldCoordinates)
assert.EqualValues(t, []coordinate{{2, 3}, {2, 3}}, driver.movedCoordinates)
assert.EqualValues(t, []coordinate{{2, 3}}, driver.releasedCoordinates)
assert.Equal(t, "", driver.failureMessage)
}

View file

@ -343,6 +343,30 @@ func (self *ViewDriver) SelectedLineIdx(expected int) *ViewDriver {
return self
}
func (self *ViewDriver) SelectedLineIdxAtLeast(expected int) *ViewDriver {
self.t.assertEventually(func() (bool, string) {
var actual int
self.t.gui.OnUIThreadAndWait(func() {
actual = self.getView().SelectedLineIdx()
})
return actual >= expected, fmt.Sprintf("%s: Expected selected line index to be at least %d, got %d", self.context, expected, actual)
})
return self
}
func (self *ViewDriver) OriginYAtLeast(expected int) *ViewDriver {
self.t.assertEventually(func() (bool, string) {
var actual int
self.t.gui.OnUIThreadAndWait(func() {
actual = self.getView().OriginY()
})
return actual >= expected, fmt.Sprintf("%s: Expected origin Y to be at least %d, got %d", self.context, expected, actual)
})
return self
}
// focus the view (assumes the view is a side-view)
func (self *ViewDriver) Focus() *ViewDriver {
viewName := self.getView().Name()
@ -483,6 +507,42 @@ func (self *ViewDriver) FocusInAndClick(x, y int) *ViewDriver {
return self
}
func (self *ViewDriver) MouseMoveToView(target *ViewDriver, x, y int) *ViewDriver {
offsetX, offsetY, _, _ := target.getView().Dimensions()
self.t.mouseMove(offsetX+1+x, offsetY+1+y)
return self
}
func (self *ViewDriver) Drag(fromX, fromY, toX, toY int) *ViewDriver {
return self.ClickAndHold(fromX, fromY).MouseMove(toX, toY).MouseRelease()
}
func (self *ViewDriver) ClickAndHold(x, y int) *ViewDriver {
offsetX, offsetY, _, _ := self.getView().Dimensions()
self.t.clickAndHold(offsetX+1+x, offsetY+1+y)
return self
}
func (self *ViewDriver) MouseMove(x, y int) *ViewDriver {
offsetX, offsetY, _, _ := self.getView().Dimensions()
self.t.mouseMove(offsetX+1+x, offsetY+1+y)
return self
}
func (self *ViewDriver) MouseMoveToBottom(x int) *ViewDriver {
return self.MouseMove(x, self.getView().InnerHeight()-1)
}
func (self *ViewDriver) RepeatMouseMove() *ViewDriver {
self.t.repeatMouseMove()
return self
}
func (self *ViewDriver) MouseRelease() *ViewDriver {
self.t.mouseRelease()
return self
}
// i.e. pressing down arrow
func (self *ViewDriver) SelectNextItem() *ViewDriver {
return self.PressFast(self.t.keys.Universal.NextItem)

View file

@ -499,6 +499,7 @@ var tests = []*components.IntegrationTest{
ui.OpenLinkFailure,
ui.PromoteTabToSidePanel,
ui.RangeSelect,
ui.RangeSelectWithAutoscroll,
ui.ReloadSidePanels,
ui.ReorderSidePanels,
ui.SwitchTabFromMenu,

View file

@ -0,0 +1,38 @@
package ui
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RangeSelectWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Keep scrolling while creating a range selection at the panel edge",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
fileContent := "base\n"
shell.CreateFileAndAdd("file1", fileContent)
for i := 1; i <= 40; i++ {
fileContent += fmt.Sprintf("line %d\n", i)
}
shell.UpdateFile("file1", fileContent)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
PressEnter()
t.Views().Staging().
ClickAndHold(1, 6).
MouseMoveToBottom(1).
OriginYAtLeast(3).
SelectedLineIdxAtLeast(9).
MouseRelease()
},
})

View file

@ -28,6 +28,12 @@ type GuiDriver interface {
// user typing faster than lazygit processes the input.
PressKeysRapidly(...string)
Click(int, int)
ClickAndHold(int, int)
MouseMove(int, int)
MouseRelease(int, int)
// Can be used to avoid data races with the UI thread in the uncommon cases that
// the test driver needs to assert state while the gui is not idle.
OnUIThreadAndWait(func())
// Simulate the terminal window regaining focus (which triggers a reload of
// changed config files)
FocusIn()