fix(terminal): keep last cursor if it's on the last row

This commit is contained in:
zeertzjq
2025-10-28 08:10:58 +08:00
parent 3fb56ff8af
commit 4ef7aa83c4
3 changed files with 180 additions and 81 deletions

View File

@@ -1246,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)); ONE_ADJUST(&(buf->b_last_change.mark.lnum));
// last cursor position, if it was set // 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)); ONE_ADJUST(&(buf->b_last_cursor.mark.lnum));
} }
@@ -1364,7 +1365,9 @@ void mark_adjust_buf(buf_T *buf, linenr_T line1, linenr_T line2, linenr_T amount
// adjust per-window "last cursor" positions // adjust per-window "last cursor" positions
for (size_t i = 0; i < kv_size(buf->b_wininfo); i++) { for (size_t i = 0; i < kv_size(buf->b_wininfo); i++) {
WinInfo *wip = kv_A(buf->b_wininfo, i); WinInfo *wip = kv_A(buf->b_wininfo, i);
ONE_ADJUST_CURSOR(&(wip->wi_mark.mark)); if (!by_term || wip->wi_mark.mark.lnum < buf->b_ml.ml_line_count) {
ONE_ADJUST_CURSOR(&(wip->wi_mark.mark));
}
} }
} }

View File

@@ -773,7 +773,7 @@ bool terminal_enter(void)
set_terminal_winopts(s); set_terminal_winopts(s);
s->term->pending.cursor = true; // Update the cursor shape table 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(); showmode();
ui_cursor_shape(); ui_cursor_shape();
@@ -2108,7 +2108,7 @@ static void refresh_terminal(Terminal *term)
refresh_screen(term, buf); refresh_screen(term, buf);
int ml_added = buf->b_ml.ml_line_count - ml_before; 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 // Copy pending events back to the main event queue
multiqueue_move_events(main_loop.events, term->pending.events); 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; 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) { FOR_ALL_TAB_WINDOWS(tp, wp) {
if (wp->w_buffer == buf) { if (wp->w_buffer == buf) {
if (wp == curwin && is_focused(term)) { if (wp == curwin && is_focused(term)) {
@@ -2334,9 +2336,7 @@ static void adjust_topline(Terminal *term, buf_T *buf, int added)
continue; continue;
} }
linenr_T ml_end = buf->b_ml.ml_line_count;
bool following = ml_end == wp->w_cursor.lnum + added; // cursor at end? bool following = ml_end == wp->w_cursor.lnum + added; // cursor at end?
if (following) { if (following) {
// "Follow" the terminal output // "Follow" the terminal output
wp->w_cursor.lnum = ml_end; 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); 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) static int row_to_linenr(Terminal *term, int row)

View File

@@ -3,7 +3,7 @@ local n = require('test.functional.testnvim')()
local Screen = require('test.functional.ui.screen') local Screen = require('test.functional.ui.screen')
local tt = require('test.functional.testterm') 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 feed, testprg = n.feed, n.testprg
local fn = n.fn local fn = n.fn
local eval = n.eval local eval = n.eval
@@ -18,34 +18,74 @@ local assert_alive = n.assert_alive
local skip = t.skip local skip = t.skip
local is_os = t.is_os local is_os = t.is_os
describe(':terminal scrollback', function() local function test_terminal_scrollback(hide_curbuf)
local screen 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
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
--- @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
api.nvim_chan_send(chan, data)
retry(nil, 1000, function()
eq({ prefix .. tostring(stop), '' }, api.nvim_buf_get_lines(buf, -3, -1, true))
end)
may_restore_curbuf()
end
before_each(function() before_each(function()
clear() clear()
command('set nostartofline jumpoptions+=view')
screen = tt.setup_screen(nil, nil, 30) screen = tt.setup_screen(nil, nil, 30)
end) buf = api.nvim_get_current_buf()
chan = api.nvim_get_option_value('channel', { buf = buf })
local function feed_new_lines_and_wait(count) if hide_curbuf then
local lines = {} otherbuf = api.nvim_create_buf(true, false)
for i = 1, count do
table.insert(lines, 'new_line' .. tostring(i))
end end
table.insert(lines, '') end)
feed_data(lines)
retry(nil, 1000, function()
eq({ 'new_line' .. tostring(count), '' }, api.nvim_buf_get_lines(0, -3, -1, true))
end)
end
describe('when the limit is exceeded', function() describe('when the limit is exceeded', function()
before_each(function() before_each(function()
local lines = {} feed_lines('line', 1, 30)
for i = 1, 30 do
table.insert(lines, 'line' .. tostring(i))
end
table.insert(lines, '')
feed_data(lines)
screen:expect([[ screen:expect([[
line26 | line26 |
line27 | line27 |
@@ -87,7 +127,7 @@ describe(':terminal scrollback', function()
end) end)
it("when outputting fewer than 'scrollback' lines", function() it("when outputting fewer than 'scrollback' lines", function()
feed_new_lines_and_wait(6) feed_lines('new_line', 1, 6)
screen:expect([[ screen:expect([[
line26 | line26 |
line27 | line27 |
@@ -102,7 +142,7 @@ describe(':terminal scrollback', function()
end) end)
it("when outputting more than 'scrollback' lines", function() it("when outputting more than 'scrollback' lines", function()
feed_new_lines_and_wait(11) feed_lines('new_line', 1, 11)
screen:expect([[ screen:expect([[
line27 | line27 |
{101:line2^8} | {101:line2^8} |
@@ -117,7 +157,7 @@ describe(':terminal scrollback', function()
end) end)
it('when outputting more lines than whole buffer', function() it('when outputting more lines than whole buffer', function()
feed_new_lines_and_wait(20) feed_lines('new_line', 1, 20)
screen:expect([[ screen:expect([[
^new_line6 | ^new_line6 |
new_line7 | new_line7 |
@@ -150,14 +190,14 @@ describe(':terminal scrollback', function()
end) end)
it("when outputting fewer than 'scrollback' lines", function() it("when outputting fewer than 'scrollback' lines", function()
feed_new_lines_and_wait(6) feed_lines('new_line', 1, 6)
screen:expect_unchanged() screen:expect_unchanged(hide_curbuf)
eq({ 0, 4, 4, 0 }, fn.getpos("'m")) eq({ 0, 4, 4, 0 }, fn.getpos("'m"))
eq({ 0, 4, 6, 0 }, fn.getpos('.')) eq({ 0, 4, 6, 0 }, fn.getpos('.'))
end) end)
it("when outputting more than 'scrollback' lines", function() it("when outputting more than 'scrollback' lines", function()
feed_new_lines_and_wait(11) feed_lines('new_line', 1, 11)
screen:expect([[ screen:expect([[
^line27 | ^line27 |
line28 | line28 |
@@ -175,7 +215,7 @@ describe(':terminal scrollback', function()
describe('with cursor at last row', function() describe('with cursor at last row', function()
before_each(function() before_each(function()
feed_data({ 'line1', 'line2', 'line3', 'line4', '' }) feed_lines('line', 1, 4)
screen:expect([[ screen:expect([[
tty ready | tty ready |
line1 | line1 |
@@ -201,7 +241,7 @@ describe(':terminal scrollback', function()
it("when outputting more than 'scrollback' lines in Normal mode", function() it("when outputting more than 'scrollback' lines in Normal mode", function()
feed([[<C-\><C-N>]]) feed([[<C-\><C-N>]])
feed_new_lines_and_wait(11) feed_lines('new_line', 1, 11)
screen:expect([[ screen:expect([[
new_line7 | new_line7 |
new_line8 | new_line8 |
@@ -222,11 +262,33 @@ describe(':terminal scrollback', function()
| |
]]) ]])
eq({ 0, 2, 4, 0 }, fn.getpos("'m")) 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) end)
describe('and 1 line is printed', function() describe('and 1 line is printed', function()
before_each(function() before_each(function()
feed_data({ 'line5', '' }) feed_lines('line', 5, 5)
end) end)
it('will hide the top line', function() it('will hide the top line', function()
@@ -245,7 +307,7 @@ describe(':terminal scrollback', function()
describe('and then 3 more lines are printed', function() describe('and then 3 more lines are printed', function()
before_each(function() before_each(function()
feed_data({ 'line6', 'line7', 'line8', '' }) feed_lines('line', 6, 8)
end) end)
it('will hide the top 4 lines', function() it('will hide the top 4 lines', function()
@@ -299,7 +361,9 @@ describe(':terminal scrollback', function()
describe('and height decreased by 1', function() describe('and height decreased by 1', function()
local function will_hide_top_line() local function will_hide_top_line()
feed([[<C-\><C-N>]]) feed([[<C-\><C-N>]])
may_hide_curbuf()
screen:try_resize(screen._width - 2, screen._height - 1) screen:try_resize(screen._width - 2, screen._height - 1)
may_restore_curbuf()
screen:expect([[ screen:expect([[
{101:line2} | {101:line2} |
line3 | line3 |
@@ -316,7 +380,9 @@ describe(':terminal scrollback', function()
describe('and then decreased by 2', function() describe('and then decreased by 2', function()
before_each(function() before_each(function()
will_hide_top_line() will_hide_top_line()
may_hide_curbuf()
screen:try_resize(screen._width - 2, screen._height - 2) screen:try_resize(screen._width - 2, screen._height - 2)
may_restore_curbuf()
end) end)
it('will hide the top 3 lines', function() 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() describe('and the height is decreased by 2', function()
before_each(function() before_each(function()
may_hide_curbuf()
screen:try_resize(screen._width, screen._height - 2) screen:try_resize(screen._width, screen._height - 2)
may_restore_curbuf()
end) end)
local function will_delete_last_two_lines() local function will_delete_last_two_lines()
@@ -376,7 +444,9 @@ describe(':terminal scrollback', function()
describe('and then decreased by 1', function() describe('and then decreased by 1', function()
before_each(function() before_each(function()
will_delete_last_two_lines() will_delete_last_two_lines()
may_hide_curbuf()
screen:try_resize(screen._width, screen._height - 1) screen:try_resize(screen._width, screen._height - 1)
may_restore_curbuf()
end) end)
it('will delete the last line and hide the first', function() 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() describe('with 4 lines hidden in the scrollback', function()
before_each(function() before_each(function()
feed_data({ 'line1', 'line2', 'line3', 'line4', '' }) feed_lines('line', 1, 4)
screen:expect([[ screen:expect([[
tty ready | tty ready |
line1 | line1 |
@@ -430,7 +500,9 @@ describe(':terminal scrollback', function()
^ | ^ |
{5:-- TERMINAL --} | {5:-- TERMINAL --} |
]]) ]])
may_hide_curbuf()
screen:try_resize(screen._width, screen._height - 3) screen:try_resize(screen._width, screen._height - 3)
may_restore_curbuf()
screen:expect([[ screen:expect([[
line4 | line4 |
rows: 3, cols: 30 | rows: 3, cols: 30 |
@@ -448,7 +520,9 @@ describe(':terminal scrollback', function()
return return
end end
local function pop_then_push() local function pop_then_push()
may_hide_curbuf()
screen:try_resize(screen._width, screen._height + 1) screen:try_resize(screen._width, screen._height + 1)
may_restore_curbuf()
screen:expect([[ screen:expect([[
line4 | line4 |
rows: 3, cols: 30 | rows: 3, cols: 30 |
@@ -465,7 +539,9 @@ describe(':terminal scrollback', function()
before_each(function() before_each(function()
pop_then_push() pop_then_push()
eq(8, api.nvim_buf_line_count(0)) eq(8, api.nvim_buf_line_count(0))
may_hide_curbuf()
screen:try_resize(screen._width, screen._height + 3) screen:try_resize(screen._width, screen._height + 3)
may_restore_curbuf()
end) end)
local function pop3_then_push1() local function pop3_then_push1()
@@ -500,7 +576,9 @@ describe(':terminal scrollback', function()
before_each(function() before_each(function()
pop3_then_push1() pop3_then_push1()
feed('Gi') feed('Gi')
may_hide_curbuf()
screen:try_resize(screen._width, screen._height + 4) screen:try_resize(screen._width, screen._height + 4)
may_restore_curbuf()
end) end)
it('will show all lines and leave a blank one at the end', function() 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) 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) end)
describe(':terminal prints more lines than the screen height and exits', function() 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)")) eq((is_os('win') and '27: line' or '26: line'), eval("getline(line('w0') - 10)->trim(' ', 2)"))
end) 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() it('defaults to 10000 in :terminal buffers', function()
set_fake_shell() set_fake_shell()
command('terminal') command('terminal')