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 66fb3c659..23ca3bab4 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -73,6 +73,10 @@ 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, buttons, 0), 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_test.go b/pkg/integration/components/test_test.go index 624c71c6a..3a145bf43 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -53,6 +53,10 @@ 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() { } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 656a93fe6..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() @@ -505,6 +529,10 @@ func (self *ViewDriver) MouseMove(x, y int) *ViewDriver { 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 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 bc92c6982..4d2da7602 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -31,6 +31,9 @@ type GuiDriver interface { 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()