mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Auto-scroll when dragging to create range selection in staging view
When the pointer reaches the edge of the view during a drag (or leaves the view entirely, which mouse capture makes possible), keep scrolling and extending the selection: slowly on the innermost edge row, faster on the outermost row, and very fast beyond. Scrolling starts after a short delay so that a drag merely passing near the edge doesn't scroll. When the view loses focus mid-drag (e.g. because a popup appeared), cancel the autoscroll and the mouse capture.
This commit is contained in:
parent
f5a069342b
commit
b682fb7635
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -499,6 +499,7 @@ var tests = []*components.IntegrationTest{
|
|||
ui.OpenLinkFailure,
|
||||
ui.PromoteTabToSidePanel,
|
||||
ui.RangeSelect,
|
||||
ui.RangeSelectWithAutoscroll,
|
||||
ui.ReloadSidePanels,
|
||||
ui.ReorderSidePanels,
|
||||
ui.SwitchTabFromMenu,
|
||||
|
|
|
|||
38
pkg/integration/tests/ui/range_select_with_autoscroll.go
Normal file
38
pkg/integration/tests/ui/range_select_with_autoscroll.go
Normal 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()
|
||||
},
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue