commit c06f2f6b380523b93b52600ae23d458eb4c85a7e
parent ae826362761aca9d1bdb065c156fecfcd2e9e132
Author: zeertzjq <zeertzjq@outlook.com>
Date: Wed, 29 Oct 2025 10:51:09 +0800
Merge pull request #36364 from zeertzjq/term-last-cursor
fix(terminal): keep last cursor if it's on the last row
Diffstat:
4 files changed, 233 insertions(+), 93 deletions(-)
diff --git a/src/nvim/mark.c b/src/nvim/mark.c
@@ -1168,6 +1168,23 @@ void ex_changes(exarg_T *eap)
} \
}
+// Like ONE_ADJUST_NODEL(), but if the position is within the deleted range,
+// move it to the start of the line before the range.
+#define ONE_ADJUST_CURSOR(pp) \
+ { \
+ pos_T *posp = pp; \
+ if (posp->lnum >= line1 && posp->lnum <= line2) { \
+ if (amount == MAXLNUM) { /* line with cursor is deleted */ \
+ posp->lnum = MAX(line1 - 1, 1); \
+ posp->col = 0; \
+ } else { /* keep cursor on the same line */ \
+ posp->lnum += amount; \
+ } \
+ } else if (amount_after && posp->lnum > line2) { \
+ posp->lnum += amount_after; \
+ } \
+ }
+
// Adjust marks between "line1" and "line2" (inclusive) to move "amount" lines.
// Must be called before changed_*(), appended_lines() or deleted_lines().
// May be called before or after changing the text.
@@ -1229,7 +1246,8 @@ void mark_adjust_buf(buf_T *buf, linenr_T line1, linenr_T line2, linenr_T amount
ONE_ADJUST(&(buf->b_last_change.mark.lnum));
// last cursor position, if it was set
- if (!equalpos(buf->b_last_cursor.mark, initpos)) {
+ if (!equalpos(buf->b_last_cursor.mark, initpos)
+ && (!by_term || buf->b_last_cursor.mark.lnum < buf->b_ml.ml_line_count)) {
ONE_ADJUST(&(buf->b_last_cursor.mark.lnum));
}
@@ -1332,20 +1350,7 @@ void mark_adjust_buf(buf_T *buf, linenr_T line1, linenr_T line2, linenr_T amount
}
}
if (!by_api && (by_term ? win->w_cursor.lnum < buf->b_ml.ml_line_count : win != curwin)) {
- if (win->w_cursor.lnum >= line1 && win->w_cursor.lnum <= line2) {
- if (amount == MAXLNUM) { // line with cursor is deleted
- if (line1 <= 1) {
- win->w_cursor.lnum = 1;
- } else {
- win->w_cursor.lnum = line1 - 1;
- }
- win->w_cursor.col = 0;
- } else { // keep cursor on the same line
- win->w_cursor.lnum += amount;
- }
- } else if (amount_after && win->w_cursor.lnum > line2) {
- win->w_cursor.lnum += amount_after;
- }
+ ONE_ADJUST_CURSOR(&(win->w_cursor));
}
if (adjust_folds) {
@@ -1356,6 +1361,14 @@ void mark_adjust_buf(buf_T *buf, linenr_T line1, linenr_T line2, linenr_T amount
// adjust diffs
diff_mark_adjust(buf, line1, line2, amount, amount_after);
+
+ // adjust per-window "last cursor" positions
+ for (size_t i = 0; i < kv_size(buf->b_wininfo); i++) {
+ WinInfo *wip = kv_A(buf->b_wininfo, i);
+ if (!by_term || wip->wi_mark.mark.lnum < buf->b_ml.ml_line_count) {
+ ONE_ADJUST_CURSOR(&(wip->wi_mark.mark));
+ }
+ }
}
// This code is used often, needs to be fast.
diff --git a/src/nvim/terminal.c b/src/nvim/terminal.c
@@ -773,7 +773,7 @@ bool terminal_enter(void)
set_terminal_winopts(s);
s->term->pending.cursor = true; // Update the cursor shape table
- adjust_topline(s->term, buf, 0); // scroll to end
+ adjust_topline_cursor(s->term, buf, 0); // scroll to end
showmode();
ui_cursor_shape();
@@ -2108,7 +2108,7 @@ static void refresh_terminal(Terminal *term)
refresh_screen(term, buf);
int ml_added = buf->b_ml.ml_line_count - ml_before;
- adjust_topline(term, buf, ml_added);
+ adjust_topline_cursor(term, buf, ml_added);
// Copy pending events back to the main event queue
multiqueue_move_events(main_loop.events, term->pending.events);
@@ -2324,8 +2324,10 @@ static void refresh_screen(Terminal *term, buf_T *buf)
term->invalid_end = -1;
}
-static void adjust_topline(Terminal *term, buf_T *buf, int added)
+static void adjust_topline_cursor(Terminal *term, buf_T *buf, int added)
{
+ linenr_T ml_end = buf->b_ml.ml_line_count;
+
FOR_ALL_TAB_WINDOWS(tp, wp) {
if (wp->w_buffer == buf) {
if (wp == curwin && is_focused(term)) {
@@ -2334,9 +2336,7 @@ static void adjust_topline(Terminal *term, buf_T *buf, int added)
continue;
}
- linenr_T ml_end = buf->b_ml.ml_line_count;
bool following = ml_end == wp->w_cursor.lnum + added; // cursor at end?
-
if (following) {
// "Follow" the terminal output
wp->w_cursor.lnum = ml_end;
@@ -2348,6 +2348,17 @@ static void adjust_topline(Terminal *term, buf_T *buf, int added)
mb_check_adjust_col(wp);
}
}
+
+ if (ml_end == buf->b_last_cursor.mark.lnum + added) {
+ buf->b_last_cursor.mark.lnum = ml_end;
+ }
+
+ for (size_t i = 0; i < kv_size(buf->b_wininfo); i++) {
+ WinInfo *wip = kv_A(buf->b_wininfo, i);
+ if (ml_end == wip->wi_mark.mark.lnum + added) {
+ wip->wi_mark.mark.lnum = ml_end;
+ }
+ }
}
static int row_to_linenr(Terminal *term, int row)
diff --git a/test/functional/terminal/scrollback_spec.lua b/test/functional/terminal/scrollback_spec.lua
@@ -3,7 +3,7 @@ local n = require('test.functional.testnvim')()
local Screen = require('test.functional.ui.screen')
local tt = require('test.functional.testterm')
-local clear, eq = n.clear, t.eq
+local clear, eq, neq = n.clear, t.eq, t.neq
local feed, testprg = n.feed, n.testprg
local fn = n.fn
local eval = n.eval
@@ -18,34 +18,74 @@ local assert_alive = n.assert_alive
local skip = t.skip
local is_os = t.is_os
-describe(':terminal scrollback', function()
- local screen
+local function test_terminal_scrollback(hide_curbuf)
+ local screen --- @type test.functional.ui.screen
+ local buf --- @type integer
+ local chan --- @type integer
+ local otherbuf --- @type integer
+ local restore_terminal_mode --- @type boolean?
+
+ local function may_hide_curbuf()
+ if hide_curbuf then
+ eq(nil, restore_terminal_mode)
+ restore_terminal_mode = vim.startswith(api.nvim_get_mode().mode, 't')
+ api.nvim_set_current_buf(otherbuf)
+ end
+ end
- before_each(function()
- clear()
- screen = tt.setup_screen(nil, nil, 30)
- end)
+ local function may_restore_curbuf()
+ if hide_curbuf then
+ neq(nil, restore_terminal_mode)
+ eq(buf, fn.bufnr('#'))
+ feed('<C-^>') -- "view" in 'jumpoptions' applies to this
+ if restore_terminal_mode then
+ feed('i')
+ else
+ -- Cursor position was restored from wi_mark, not b_last_cursor.
+ -- Check that b_last_cursor and wi_mark are the same.
+ local last_cursor = fn.getpos([['"]])
+ local restored_cursor = fn.getpos('.')
+ if last_cursor[2] > 0 then
+ eq(restored_cursor, last_cursor)
+ else
+ eq({ 0, 0, 0, 0 }, last_cursor)
+ eq({ 0, 1, 1, 0 }, restored_cursor)
+ end
+ end
+ restore_terminal_mode = nil
+ end
+ end
- local function feed_new_lines_and_wait(count)
- local lines = {}
- for i = 1, count do
- table.insert(lines, 'new_line' .. tostring(i))
+ --- @param prefix string
+ --- @param start integer
+ --- @param stop integer
+ local function feed_lines(prefix, start, stop)
+ may_hide_curbuf()
+ local data = ''
+ for i = start, stop do
+ data = data .. prefix .. tostring(i) .. '\n'
end
- table.insert(lines, '')
- feed_data(lines)
+ api.nvim_chan_send(chan, data)
retry(nil, 1000, function()
- eq({ 'new_line' .. tostring(count), '' }, api.nvim_buf_get_lines(0, -3, -1, true))
+ eq({ prefix .. tostring(stop), '' }, api.nvim_buf_get_lines(buf, -3, -1, true))
end)
+ may_restore_curbuf()
end
+ before_each(function()
+ clear()
+ command('set nostartofline jumpoptions+=view')
+ screen = tt.setup_screen(nil, nil, 30)
+ buf = api.nvim_get_current_buf()
+ chan = api.nvim_get_option_value('channel', { buf = buf })
+ if hide_curbuf then
+ otherbuf = api.nvim_create_buf(true, false)
+ end
+ end)
+
describe('when the limit is exceeded', function()
before_each(function()
- local lines = {}
- for i = 1, 30 do
- table.insert(lines, 'line' .. tostring(i))
- end
- table.insert(lines, '')
- feed_data(lines)
+ feed_lines('line', 1, 30)
screen:expect([[
line26 |
line27 |
@@ -87,7 +127,7 @@ describe(':terminal scrollback', function()
end)
it("when outputting fewer than 'scrollback' lines", function()
- feed_new_lines_and_wait(6)
+ feed_lines('new_line', 1, 6)
screen:expect([[
line26 |
line27 |
@@ -102,7 +142,7 @@ describe(':terminal scrollback', function()
end)
it("when outputting more than 'scrollback' lines", function()
- feed_new_lines_and_wait(11)
+ feed_lines('new_line', 1, 11)
screen:expect([[
line27 |
{101:line2^8} |
@@ -117,7 +157,7 @@ describe(':terminal scrollback', function()
end)
it('when outputting more lines than whole buffer', function()
- feed_new_lines_and_wait(20)
+ feed_lines('new_line', 1, 20)
screen:expect([[
^new_line6 |
new_line7 |
@@ -150,14 +190,14 @@ describe(':terminal scrollback', function()
end)
it("when outputting fewer than 'scrollback' lines", function()
- feed_new_lines_and_wait(6)
- screen:expect_unchanged()
+ feed_lines('new_line', 1, 6)
+ screen:expect_unchanged(hide_curbuf)
eq({ 0, 4, 4, 0 }, fn.getpos("'m"))
eq({ 0, 4, 6, 0 }, fn.getpos('.'))
end)
it("when outputting more than 'scrollback' lines", function()
- feed_new_lines_and_wait(11)
+ feed_lines('new_line', 1, 11)
screen:expect([[
^line27 |
line28 |
@@ -175,7 +215,7 @@ describe(':terminal scrollback', function()
describe('with cursor at last row', function()
before_each(function()
- feed_data({ 'line1', 'line2', 'line3', 'line4', '' })
+ feed_lines('line', 1, 4)
screen:expect([[
tty ready |
line1 |
@@ -201,7 +241,7 @@ describe(':terminal scrollback', function()
it("when outputting more than 'scrollback' lines in Normal mode", function()
feed([[<C-\><C-N>]])
- feed_new_lines_and_wait(11)
+ feed_lines('new_line', 1, 11)
screen:expect([[
new_line7 |
new_line8 |
@@ -222,11 +262,33 @@ describe(':terminal scrollback', function()
|
]])
eq({ 0, 2, 4, 0 }, fn.getpos("'m"))
+ feed('G')
+ feed_lines('new_line', 12, 31)
+ screen:expect([[
+ new_line27 |
+ new_line28 |
+ new_line29 |
+ new_line30 |
+ new_line31 |
+ ^ |
+ |
+ ]])
+ feed('gg')
+ screen:expect([[
+ ^new_line17 |
+ new_line18 |
+ new_line19 |
+ new_line20 |
+ new_line21 |
+ new_line22 |
+ |
+ ]])
+ eq({ 0, 0, 0, 0 }, fn.getpos("'m"))
end)
describe('and 1 line is printed', function()
before_each(function()
- feed_data({ 'line5', '' })
+ feed_lines('line', 5, 5)
end)
it('will hide the top line', function()
@@ -245,7 +307,7 @@ describe(':terminal scrollback', function()
describe('and then 3 more lines are printed', function()
before_each(function()
- feed_data({ 'line6', 'line7', 'line8', '' })
+ feed_lines('line', 6, 8)
end)
it('will hide the top 4 lines', function()
@@ -299,7 +361,9 @@ describe(':terminal scrollback', function()
describe('and height decreased by 1', function()
local function will_hide_top_line()
feed([[<C-\><C-N>]])
+ may_hide_curbuf()
screen:try_resize(screen._width - 2, screen._height - 1)
+ may_restore_curbuf()
screen:expect([[
{101:line2} |
line3 |
@@ -316,7 +380,9 @@ describe(':terminal scrollback', function()
describe('and then decreased by 2', function()
before_each(function()
will_hide_top_line()
+ may_hide_curbuf()
screen:try_resize(screen._width - 2, screen._height - 2)
+ may_restore_curbuf()
end)
it('will hide the top 3 lines', function()
@@ -357,7 +423,9 @@ describe(':terminal scrollback', function()
describe('and the height is decreased by 2', function()
before_each(function()
+ may_hide_curbuf()
screen:try_resize(screen._width, screen._height - 2)
+ may_restore_curbuf()
end)
local function will_delete_last_two_lines()
@@ -376,7 +444,9 @@ describe(':terminal scrollback', function()
describe('and then decreased by 1', function()
before_each(function()
will_delete_last_two_lines()
+ may_hide_curbuf()
screen:try_resize(screen._width, screen._height - 1)
+ may_restore_curbuf()
end)
it('will delete the last line and hide the first', function()
@@ -408,7 +478,7 @@ describe(':terminal scrollback', function()
describe('with 4 lines hidden in the scrollback', function()
before_each(function()
- feed_data({ 'line1', 'line2', 'line3', 'line4', '' })
+ feed_lines('line', 1, 4)
screen:expect([[
tty ready |
line1 |
@@ -430,7 +500,9 @@ describe(':terminal scrollback', function()
^ |
{5:-- TERMINAL --} |
]])
+ may_hide_curbuf()
screen:try_resize(screen._width, screen._height - 3)
+ may_restore_curbuf()
screen:expect([[
line4 |
rows: 3, cols: 30 |
@@ -448,7 +520,9 @@ describe(':terminal scrollback', function()
return
end
local function pop_then_push()
+ may_hide_curbuf()
screen:try_resize(screen._width, screen._height + 1)
+ may_restore_curbuf()
screen:expect([[
line4 |
rows: 3, cols: 30 |
@@ -465,7 +539,9 @@ describe(':terminal scrollback', function()
before_each(function()
pop_then_push()
eq(8, api.nvim_buf_line_count(0))
+ may_hide_curbuf()
screen:try_resize(screen._width, screen._height + 3)
+ may_restore_curbuf()
end)
local function pop3_then_push1()
@@ -500,7 +576,9 @@ describe(':terminal scrollback', function()
before_each(function()
pop3_then_push1()
feed('Gi')
+ may_hide_curbuf()
screen:try_resize(screen._width, screen._height + 4)
+ may_restore_curbuf()
end)
it('will show all lines and leave a blank one at the end', function()
@@ -527,6 +605,55 @@ describe(':terminal scrollback', function()
end)
end)
end)
+
+ it('reducing &scrollback deletes extra lines immediately', function()
+ feed_lines('line', 1, 30)
+ screen:expect([[
+ line26 |
+ line27 |
+ line28 |
+ line29 |
+ line30 |
+ ^ |
+ {5:-- TERMINAL --} |
+ ]])
+ local term_height = 6 -- Actual terminal screen height, not the scrollback
+ -- Initial
+ local scrollback = api.nvim_get_option_value('scrollback', { buf = buf })
+ eq(scrollback + term_height, fn.line('$'))
+ eq(scrollback + term_height, fn.line('.'))
+ n.fn.setpos("'m", { 0, scrollback + 1, 4, 0 })
+ local ns = api.nvim_create_namespace('test')
+ api.nvim_buf_set_extmark(0, ns, scrollback, 0, { end_col = 6, hl_group = 'ErrorMsg' })
+ screen:expect([[
+ {101:line26} |
+ line27 |
+ line28 |
+ line29 |
+ line30 |
+ ^ |
+ {5:-- TERMINAL --} |
+ ]])
+ -- Reduction
+ scrollback = scrollback - 2
+ may_hide_curbuf()
+ api.nvim_set_option_value('scrollback', scrollback, { buf = buf })
+ may_restore_curbuf()
+ eq(scrollback + term_height, fn.line('$'))
+ eq(scrollback + term_height, fn.line('.'))
+ screen:expect_unchanged(hide_curbuf)
+ eq({ 0, scrollback + 1, 4, 0 }, n.fn.getpos("'m"))
+ end)
+end
+
+describe(':terminal scrollback', function()
+ describe('in current buffer', function()
+ test_terminal_scrollback(false)
+ end)
+
+ describe('in hidden buffer', function()
+ test_terminal_scrollback(true)
+ end)
end)
describe(':terminal prints more lines than the screen height and exits', function()
@@ -652,48 +779,6 @@ describe("'scrollback' option", function()
eq((is_os('win') and '27: line' or '26: line'), eval("getline(line('w0') - 10)->trim(' ', 2)"))
end)
- it('deletes extra lines immediately', function()
- -- Scrollback is 10 on setup_screen
- local screen = tt.setup_screen(nil, nil, 30)
- local lines = {}
- for i = 1, 30 do
- table.insert(lines, 'line' .. tostring(i))
- end
- table.insert(lines, '')
- feed_data(lines)
- screen:expect([[
- line26 |
- line27 |
- line28 |
- line29 |
- line30 |
- ^ |
- {5:-- TERMINAL --} |
- ]])
- local ns = api.nvim_create_namespace('test')
- local term_height = 6 -- Actual terminal screen height, not the scrollback
- -- Initial
- local scrollback = api.nvim_get_option_value('scrollback', {})
- eq(scrollback + term_height, fn.line('$'))
- n.fn.setpos("'m", { 0, scrollback + 1, 4, 0 })
- api.nvim_buf_set_extmark(0, ns, scrollback, 0, { end_col = 6, hl_group = 'ErrorMsg' })
- screen:expect([[
- {101:line26} |
- line27 |
- line28 |
- line29 |
- line30 |
- ^ |
- {5:-- TERMINAL --} |
- ]])
- -- Reduction
- scrollback = scrollback - 2
- api.nvim_set_option_value('scrollback', scrollback, {})
- eq(scrollback + term_height, fn.line('$'))
- screen:expect_unchanged()
- eq({ 0, scrollback + 1, 4, 0 }, n.fn.getpos("'m"))
- end)
-
it('defaults to 10000 in :terminal buffers', function()
set_fake_shell()
command('terminal')
diff --git a/test/old/testdir/test_buffer.vim b/test/old/testdir/test_buffer.vim
@@ -619,17 +619,48 @@ func Test_switch_to_previously_viewed_buffer()
vsplit
call cursor(100, 3)
+ call assert_equal('100', getline('.'))
edit Xotherbuf
buffer Xviewbuf
call assert_equal([0, 100, 3, 0], getpos('.'))
+ call assert_equal('100', getline('.'))
+ edit Xotherbuf
+ wincmd p
+ normal! gg10dd
+ wincmd p
+ buffer Xviewbuf
+ call assert_equal([0, 90, 3, 0], getpos('.'))
+ call assert_equal('100', getline('.'))
+
+ edit Xotherbuf
+ wincmd p
+ normal! ggP
+ wincmd p
+ buffer Xviewbuf
+ call assert_equal([0, 100, 3, 0], getpos('.'))
+ call assert_equal('100', getline('.'))
+
+ edit Xotherbuf
+ wincmd p
+ normal! 96gg10ddgg
+ wincmd p
+ buffer Xviewbuf
+ " The original cursor line was deleted, so cursor is restored to the start
+ " of the line before the deleted range.
+ call assert_equal([0, 95, 1, 0], getpos('.'))
+ call assert_equal('95', getline('.'))
+
+ normal! u
exe win_id2win(oldwin) .. 'close'
setlocal bufhidden=hide
call cursor(200, 3)
+ call assert_equal('200', getline('.'))
edit Xotherbuf
buffer Xviewbuf
call assert_equal([0, 200, 3, 0], getpos('.'))
+ call assert_equal('200', getline('.'))
bwipe! Xotherbuf
bwipe! Xviewbuf