diff --git a/pkg/gui/controllers/helpers/drag_autoscroller.go b/pkg/gui/controllers/helpers/drag_autoscroller.go new file mode 100644 index 000000000..7ec6a9c76 --- /dev/null +++ b/pkg/gui/controllers/helpers/drag_autoscroller.go @@ -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 + } +} diff --git a/pkg/gui/controllers/helpers/drag_autoscroller_test.go b/pkg/gui/controllers/helpers/drag_autoscroller_test.go new file mode 100644 index 000000000..40faa789e --- /dev/null +++ b/pkg/gui/controllers/helpers/drag_autoscroller_test.go @@ -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) +} diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index aa5fd54bb..70dfb8f4e 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -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() diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 9e06f483b..23ca3bab4 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -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() diff --git a/pkg/integration/components/assertion_helper.go b/pkg/integration/components/assertion_helper.go index 0529e8bec..0a1d97b8c 100644 --- a/pkg/integration/components/assertion_helper.go +++ b/pkg/integration/components/assertion_helper.go @@ -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) } diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 19219707a..d65caee5d 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -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(...) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 7196779eb..3a145bf43 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -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) } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 2cfaba338..937c19b7b 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -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) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 77e54e265..0fa259234 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -499,6 +499,7 @@ var tests = []*components.IntegrationTest{ ui.OpenLinkFailure, ui.PromoteTabToSidePanel, ui.RangeSelect, + ui.RangeSelectWithAutoscroll, ui.ReloadSidePanels, ui.ReorderSidePanels, ui.SwitchTabFromMenu, diff --git a/pkg/integration/tests/ui/range_select_with_autoscroll.go b/pkg/integration/tests/ui/range_select_with_autoscroll.go new file mode 100644 index 000000000..83d2bcba3 --- /dev/null +++ b/pkg/integration/tests/ui/range_select_with_autoscroll.go @@ -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() + }, +}) diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index ea76c45be..4d2da7602 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -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()