diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index 1cb8d9412..87cf270dd 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -78,6 +78,11 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error // not-yet-loaded content. gui.getManager(view).StartLoading() + // Read any requested scroll-restore now so we can size the initial read to it + // in afterLayout; the task itself clears the request and applies the scroll at + // its first paint. + targetOriginY := gui.getManager(view).GetScrollToOriginYForNextTask() + // Run the pty after layout so that it gets the correct size gui.afterLayout(func() error { // Need to get the width and the pager command again because the layout might have @@ -142,7 +147,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error gui.Mutexes.PtyMutex.Unlock() } - linesToRead := gui.linesToReadFromCmdTask(view) + linesToRead := gui.linesToReadFromCmdTask(view, targetOriginY) return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr) }) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 3c9eef353..38543cd37 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -30,6 +30,11 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error // still-running writes (see View.SetContentWidth). contentWidth := view.InnerWidth() + // If a caller asked us to restore a scroll position for this render, size the + // initial read to it (below) and let the task scroll there at its first paint. + // The task clears the request and suppresses the origin reset when it starts. + targetOriginY := manager.GetScrollToOriginYForNextTask() + var r io.ReadCloser start := func() (tasks.Cmd, io.Reader) { view.SetContentWidth(contentWidth) @@ -46,7 +51,7 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error } } - linesToRead := gui.linesToReadFromCmdTask(view) + linesToRead := gui.linesToReadFromCmdTask(view, targetOriginY) if err := manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr); err != nil { gui.c.Log.Error(err) } diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index d139984fa..0a9d03e51 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -19,9 +19,22 @@ func (gui *Gui) resetViewOrigin(v *gocui.View) { // Returns the number of lines that we should read initially from a cmd task so // that the scrollbar has the correct size, along with the number of lines after // which the view is filled and we can do a first refresh. -func (gui *Gui) linesToReadFromCmdTask(v *gocui.View) tasks.LinesToRead { +// +// If targetOriginY is non-nil, the read is sized to that scroll position rather +// than the view's current one, and the returned LinesToRead carries an +// ApplyInitialScroll that scrolls the view there at the first refresh. This is +// used when re-rendering content the user was already scrolled into, so the +// saved position is applied exactly when the content first paints. +func (gui *Gui) linesToReadFromCmdTask(v *gocui.View, targetOriginY *int) tasks.LinesToRead { height := v.InnerHeight() oy := v.OriginY() + var applyInitialScroll func() + if targetOriginY != nil { + oy = *targetOriginY + applyInitialScroll = func() { + v.SetOrigin(v.OriginX(), *targetOriginY) + } + } linesForFirstRefresh := height + oy + 10 @@ -37,6 +50,7 @@ func (gui *Gui) linesToReadFromCmdTask(v *gocui.View) tasks.LinesToRead { return tasks.LinesToRead{ Total: linesToReadForAccurateScrollbar, InitialRefreshAfter: linesForFirstRefresh, + ApplyInitialScroll: applyInitialScroll, } } diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index e010a77b5..083519a8f 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -74,6 +74,22 @@ type ViewBufferManager struct { taskKey string onNewKey func() + // When non-nil, the next cmd/pty task restores this scroll position. Used + // when re-rendering content the user was already scrolled into (e.g. when + // returning to a focused main view on escape). It has two effects: + // + // - The task does not reset the view's origin to the top at start, even if + // its command key differs from the previous task's. This keeps the + // placeholder that CopyContent left in the view showing at its current + // scroll position (i.e. "as if nothing changed") until the real content + // is ready, rather than flicking it to the top. + // - The task sizes its initial read to this position and scrolls there as + // part of its first refresh, so the saved scroll is applied in the same + // paint that shows the real content. + // + // Cleared once the next task has started. + scrollToOriginYForNextTask *int + // Whether a command task is currently reading content into the view. While // this is true the content is still growing, so callers (e.g. the layout) // must not clamp the view's scroll position to the amount loaded so far. @@ -117,6 +133,12 @@ type LinesToRead struct { // subsequent requests. InitialRefreshAfter int + // When set, called once, just before the view is first refreshed, to scroll + // it to a saved position. Used so that content the user was scrolled into is + // painted at the saved scroll position the first time it appears, rather than + // at the top. Only set for the initial read request. + ApplyInitialScroll func() + // Function to call after reading the lines is done Then func() } @@ -160,6 +182,22 @@ func (self *ViewBufferManager) ReadLines(totalLines int) { } } +// ScrollToOriginYForNextTask makes the next cmd/pty task restore the given +// scroll position instead of rendering at the top. Call this right before +// triggering a re-render of content the view is already scrolled into (e.g. +// when returning to a focused main view on escape). See the field doc for the +// two effects this has. It is cleared once the next task starts. +func (self *ViewBufferManager) ScrollToOriginYForNextTask(originY int) { + self.scrollToOriginYForNextTask = &originY +} + +// GetScrollToOriginYForNextTask returns the scroll position requested by a +// preceding ScrollToOriginYForNextTask call, or nil if none. It does not clear +// it; the task clears it when it starts. +func (self *ViewBufferManager) GetScrollToOriginYForNextTask() *int { + return self.scrollToOriginYForNextTask +} + // IsLoading reports whether a command task is currently reading content into the // view, meaning the content is still growing. func (self *ViewBufferManager) IsLoading() bool { @@ -335,6 +373,18 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix // this to work out how many more lines, if any, we still need to read. linesRead := 0 + // The initial read request carries an optional scroll-to function (see + // LinesToRead.ApplyInitialScroll). We apply it exactly once, right before + // the view is first refreshed, so that content the user was scrolled into + // is painted at the saved position the first time it appears. + initialScroll := linesToRead.ApplyInitialScroll + var applyInitialScrollOnce sync.Once + applyInitialScroll := func() { + if initialScroll != nil { + applyInitialScrollOnce.Do(initialScroll) + } + } + // Set LAZYGIT_SLOW_RENDER= to sleep that long after each // line is written to the view, stretching async loads out so the frames // of a re-render become visible. Useful for debugging scroll/flicker @@ -387,11 +437,14 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix if !ok { // if we're here then there's nothing left to scan from the source - // so we're at the EOF and can flush the stale content. + // so we're at the EOF and can flush the stale content. Apply the + // saved scroll first (if any) so that onEndOfInput clamps it back + // into range when the new content turned out shorter than expected. // onEndOfInput reads the view's dimensions (to decide // whether to scroll) and sets the origin, both of which // are UI-thread-only, so run it there. _ = self.onUIThread(func() error { + applyInitialScroll() self.onEndOfInput() return nil }) @@ -428,8 +481,11 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix if linesRead == linesToRead.InitialRefreshAfter { // We have read enough lines to fill the view, so do a first refresh - // here to show what we have. Continue reading and refresh again at - // the end to make sure the scrollbar has the right size. + // here to show what we have. Apply the saved scroll first (if any) + // so the first paint already lands at it. Continue reading and + // refresh again at the end to make sure the scrollbar has the right + // size. + applyInitialScroll() refreshViewIfStale() } } @@ -548,7 +604,12 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error return } - resetOrigin := self.GetTaskKey() != key && self.onNewKey != nil + // Reset the origin to the top when the command changed, unless a caller + // asked us to restore a scroll position: in that case we keep the + // placeholder showing at its current scroll until the task scrolls to the + // saved position as part of its first paint (see scrollToOriginYForNextTask). + resetOrigin := self.GetTaskKey() != key && self.onNewKey != nil && self.scrollToOriginYForNextTask == nil + self.scrollToOriginYForNextTask = nil self.taskKey = key self.taskIDMutex.Unlock() diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go index 2cea139e8..c5266e5ed 100644 --- a/pkg/tasks/tasks_test.go +++ b/pkg/tasks/tasks_test.go @@ -54,7 +54,7 @@ func TestNewCmdTaskInstantStop(t *testing.T) { return ExecCmd{Cmd: cmd}, reader } - fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) + fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{Total: 20, InitialRefreshAfter: -1}, onDone) _ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }}) @@ -119,7 +119,7 @@ func TestNewCmdTask(t *testing.T) { return ExecCmd{Cmd: cmd}, reader } - fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) + fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{Total: 20, InitialRefreshAfter: -1}, onDone) wg := sync.WaitGroup{} wg.Go(func() { time.Sleep(100 * time.Millisecond) @@ -186,37 +186,37 @@ func TestNewCmdTaskRefresh(t *testing.T) { { "total < initialRefreshAfter", 150, - LinesToRead{100, 120, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 120}, []int{100}, }, { "total == initialRefreshAfter", 150, - LinesToRead{100, 100, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 100}, []int{100}, }, { "total > initialRefreshAfter", 150, - LinesToRead{100, 50, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 50}, []int{50, 100}, }, { "initialRefreshAfter == -1", 150, - LinesToRead{100, -1, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: -1}, []int{100}, }, { "totalTaskLines < initialRefreshAfter", 25, - LinesToRead{100, 50, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 50}, []int{25}, }, { "totalTaskLines between total and initialRefreshAfter", 75, - LinesToRead{100, 50, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 50}, []int{50, 75}, }, }