From 450ba41436b6f9a94c7519339a0bdff8dfbf66a9 Mon Sep 17 00:00:00 2001 From: glepnir Date: Tue, 19 May 2026 01:08:26 +0800 Subject: [PATCH] fix(api): adjust Visual position after nvim_buf_set_text #30690 Problem: Visual selection could end up in the wrong place after nvim_buf_set_text or nvim_buf_set_lines. In some delete cases, Visual.lnum was already clamped before the line shift happened, so the adjustment got skipped. Solution: Split fix_cursor_cols into reusable fix_pos_col logic and reuse it for Visual updates. Also adjust Visual.lnum before changed_lines so the shift uses the original position before final clamping. --- src/nvim/api/buffer.c | 154 +++++++++++++++++----------- test/functional/api/buffer_spec.lua | 104 +++++++++++++++++++ 2 files changed, 198 insertions(+), 60 deletions(-) diff --git a/src/nvim/api/buffer.c b/src/nvim/api/buffer.c index 9194df1f49..5ddddb1ad1 100644 --- a/src/nvim/api/buffer.c +++ b/src/nvim/api/buffer.c @@ -435,6 +435,13 @@ void nvim_buf_set_lines(uint64_t channel_id, Buffer buf, Integer start, Integer mark_adjust_buf(b, (linenr_T)start, (linenr_T)(end - 1), adjust, (linenr_T)extra, true, kMarkAdjustApi, kExtmarkNOOP); + if (VIsual_active && b == curbuf && VIsual.lnum >= (linenr_T)start) { + if (VIsual.lnum >= (linenr_T)end) { + VIsual.lnum += (linenr_T)extra; + } + check_visual_pos(); + } + extmark_splice(b, (int)start - 1, 0, (int)(end - start), 0, deleted_bytes, (int)new_len, 0, inserted_bytes, kExtmarkUndo); @@ -662,6 +669,12 @@ void nvim_buf_set_text(uint64_t channel_id, Buffer buf, Integer start_row, Integ mark_adjust_buf(b, (linenr_T)start_row, (linenr_T)end_row - 1, adjust, (linenr_T)extra, true, kMarkAdjustApi, kExtmarkNOOP); + if (VIsual_active && b == curbuf && VIsual_mode != Ctrl_V) { + fix_pos_col(b, &VIsual, (linenr_T)start_row, (colnr_T)start_col, (linenr_T)end_row, + (colnr_T)end_col, (linenr_T)new_len, (colnr_T)last_item.size, 1); + check_visual_pos(); + } + extmark_splice(b, (int)start_row - 1, (colnr_T)start_col, (int)(end_row - start_row), col_extent, old_byte, (int)new_len - 1, (colnr_T)last_item.size, new_byte, @@ -1275,6 +1288,83 @@ static void fix_cursor(win_T *win, linenr_T lo, linenr_T hi, linenr_T extra) } } +/// Adjust pos's col/lnum after text replacement between +/// (start_row, start_col) and (end_row, end_col). +static void fix_pos_col(buf_T *buf, pos_T *pos, linenr_T start_row, colnr_T start_col, + linenr_T end_row, colnr_T end_col, linenr_T new_rows, + colnr_T new_cols_at_end_row, colnr_T mode_col_adj) +{ + if (pos->lnum < start_row) { + return; + } + + linenr_T old_rows = end_row - start_row + 1; + linenr_T lnum_shift = new_rows - old_rows; + + if (pos->lnum > end_row) { + pos->lnum += lnum_shift; + return; + } + + colnr_T end_row_change_start = new_rows == 1 ? start_col : 0; + colnr_T end_row_change_end = end_row_change_start + new_cols_at_end_row; + + // check if pos is after replaced range or not + if (pos->lnum == end_row && pos->col + mode_col_adj > end_col) { + // if pos is after replaced range, it's shifted + // to keep its position the same, relative to end_col + pos->lnum += lnum_shift; + pos->col += end_row_change_end - end_col; + return; + } + + // if pos is inside replaced range + // and the new range got smaller, + // it's shifted to keep it inside the new range + // + // if pos is before range or range did not + // get smaller, position is not changed + + colnr_T old_coladd = pos->coladd; + + // it's easier to work with a single value here. + // col and coladd are fixed by a later call + // to check_cursor_col when necessary + pos->col += pos->coladd; + pos->coladd = 0; + + linenr_T new_end_row = start_row + new_rows - 1; + + // make sure pos row is in the new row range + if (pos->lnum > new_end_row) { + pos->lnum = new_end_row; + + // don't simply move pos up, but to the end + // of new_end_row, if it's not at or after + // it already (in case virtualedit is active) + // column might be additionally adjusted below + // to keep it inside col range if needed + colnr_T len = ml_get_buf_len(buf, new_end_row); + if (pos->col < len) { + pos->col = len; + } + } + + // if pos is at the last row and + // it wasn't after eol before, move it exactly + // to end_row_change_end + if (pos->lnum == new_end_row + && pos->col > end_row_change_end && old_coladd == 0) { + pos->col = end_row_change_end; + + // make sure pos is inside range, not after it, + // except when doing so would move it before new range + if (pos->col - mode_col_adj >= end_row_change_start) { + pos->col -= mode_col_adj; + } + } +} + /// Fix cursor position after replacing text /// between (start_row, start_col) and (end_row, end_col). /// @@ -1282,66 +1372,10 @@ static void fix_cursor(win_T *win, linenr_T lo, linenr_T hi, linenr_T extra) static void fix_cursor_cols(win_T *win, linenr_T start_row, colnr_T start_col, linenr_T end_row, colnr_T end_col, linenr_T new_rows, colnr_T new_cols_at_end_row) { - colnr_T mode_col_adj = win == curwin && (State & MODE_INSERT) ? 0 : 1; - - colnr_T end_row_change_start = new_rows == 1 ? start_col : 0; - colnr_T end_row_change_end = end_row_change_start + new_cols_at_end_row; - - // check if cursor is after replaced range or not - if (win->w_cursor.lnum == end_row && win->w_cursor.col + mode_col_adj > end_col) { - // if cursor is after replaced range, it's shifted - // to keep it's position the same, relative to end_col - - linenr_T old_rows = end_row - start_row + 1; - win->w_cursor.lnum += new_rows - old_rows; - win->w_cursor.col += end_row_change_end - end_col; - } else { - // if cursor is inside replaced range - // and the new range got smaller, - // it's shifted to keep it inside the new range - // - // if cursor is before range or range did not - // got smaller, position is not changed - - colnr_T old_coladd = win->w_cursor.coladd; - - // it's easier to work with a single value here. - // col and coladd are fixed by a later call - // to check_cursor_col when necessary - win->w_cursor.col += win->w_cursor.coladd; - win->w_cursor.coladd = 0; - - linenr_T new_end_row = start_row + new_rows - 1; - - // make sure cursor row is in the new row range - if (win->w_cursor.lnum > new_end_row) { - win->w_cursor.lnum = new_end_row; - - // don't simply move cursor up, but to the end - // of new_end_row, if it's not at or after - // it already (in case virtualedit is active) - // column might be additionally adjusted below - // to keep it inside col range if needed - colnr_T len = ml_get_buf_len(win->w_buffer, new_end_row); - if (win->w_cursor.col < len) { - win->w_cursor.col = len; - } - } - - // if cursor is at the last row and - // it wasn't after eol before, move it exactly - // to end_row_change_end - if (win->w_cursor.lnum == new_end_row - && win->w_cursor.col > end_row_change_end && old_coladd == 0) { - win->w_cursor.col = end_row_change_end; - - // make sure cursor is inside range, not after it, - // except when doing so would move it before new range - if (win->w_cursor.col - mode_col_adj >= end_row_change_start) { - win->w_cursor.col -= mode_col_adj; - } - } - } + colnr_T mode_col_adj = (win == curwin && (State & MODE_INSERT)) ? 0 : 1; + fix_pos_col(win->w_buffer, &win->w_cursor, + start_row, start_col, end_row, end_col, + new_rows, new_cols_at_end_row, mode_col_adj); check_cursor_col(win); changed_cline_bef_curs(win); diff --git a/test/functional/api/buffer_spec.lua b/test/functional/api/buffer_spec.lua index a63ef9a58d..8395916054 100644 --- a/test/functional/api/buffer_spec.lua +++ b/test/functional/api/buffer_spec.lua @@ -1021,6 +1021,110 @@ describe('api/buf', function() eq({ 1, 4 }, api.nvim_win_get_cursor(win2)) end) + it('keep visual select position #29558', function() + insert([[1234]]) + api.nvim_win_set_cursor(0, { 1, 1 }) + feed('vl') + exec_lua([[ + vim.defer_fn(function() vim.api.nvim_buf_set_text(0, 0, 0, 0, 0, { '0' }) end, 50) + vim.wait(80) + ]]) + local mode = api.nvim_get_mode().mode + eq({ '23' }, fn.getregion(fn.getpos('.'), fn.getpos('v'), { type = mode })) + eq({ 'v', { '01234' } }, { mode, api.nvim_buf_get_lines(0, 0, -1, false) }) + feed('') + + api.nvim_buf_set_lines(0, 0, -1, false, { '1', '2', '3' }) + api.nvim_win_set_cursor(0, { 2, 0 }) + feed('v') + exec_lua([[ + vim.defer_fn(function() vim.api.nvim_buf_set_text(0, 0, 0, 0, 0, { '0', '' }) end, 50) + vim.wait(80) + ]]) + mode = api.nvim_get_mode().mode + eq({ '2' }, fn.getregion(fn.getpos('.'), fn.getpos('v'), { type = mode })) + eq({ 'v', { '0', '1', '2', '3' } }, { mode, api.nvim_buf_get_lines(0, 0, -1, false) }) + feed('') + + api.nvim_buf_set_lines(0, 0, -1, false, { '1', '2' }) + api.nvim_win_set_cursor(0, { 1, 0 }) + feed('vj') + exec_lua([[ + vim.defer_fn(function() vim.api.nvim_buf_set_text(0, 0, 0, 0, 0, { '0' }) end, 50) + vim.wait(80) + ]]) + mode = api.nvim_get_mode().mode + eq({ '1', '2' }, fn.getregion(fn.getpos('.'), fn.getpos('v'), { type = mode })) + eq({ 'v', { '01', '2' } }, { mode, api.nvim_buf_get_lines(0, 0, -1, false) }) + feed('') + + api.nvim_buf_set_lines(0, 0, -1, false, { '123' }) + api.nvim_win_set_cursor(0, { 1, 1 }) + feed('v') + exec_lua([[ + vim.defer_fn(function() vim.api.nvim_buf_set_text(0, 0, 0, 0, 0, { '', '' }) end, 50) + vim.wait(80) + ]]) + mode = api.nvim_get_mode().mode + eq({ '2' }, fn.getregion(fn.getpos('.'), fn.getpos('v'), { type = mode })) + eq({ 'v', { '', '123' } }, { mode, api.nvim_buf_get_lines(0, 0, -1, false) }) + feed('') + + -- Visual block mode + api.nvim_buf_set_lines(0, 0, -1, false, { '123', '456' }) + api.nvim_win_set_cursor(0, { 1, 0 }) + feed('jl') + exec_lua([[ + vim.defer_fn(function() vim.api.nvim_buf_set_text(0, 0, 0, 0, 0, { '0' }) end, 50) + vim.wait(80) + ]]) + mode = api.nvim_get_mode().mode + eq({ '01', '45' }, fn.getregion(fn.getpos('.'), fn.getpos('v'), { type = mode })) + eq( + { string.char(0x16), { '0123', '456' } }, + { mode, api.nvim_buf_get_lines(0, 0, -1, false) } + ) + feed('') + -- also test nvim_buf_set_lines inserts line above visual selection + api.nvim_buf_set_lines(0, 0, -1, false, { '1', '2', '3' }) + api.nvim_win_set_cursor(0, { 2, 0 }) + feed('v') + exec_lua([[ + vim.defer_fn(function() vim.api.nvim_buf_set_lines(0, 0, 0, false, { 'new' }) end, 50) + vim.wait(80) + ]]) + mode = api.nvim_get_mode().mode + eq({ '2' }, fn.getregion(fn.getpos('.'), fn.getpos('v'), { type = mode })) + eq({ 'v', { 'new', '1', '2', '3' } }, { mode, api.nvim_buf_get_lines(0, 0, -1, false) }) + feed('') + + api.nvim_buf_set_lines(0, 0, -1, false, { '1234' }) + api.nvim_win_set_cursor(0, { 1, 1 }) + feed('vl') + exec_lua([[ + vim.defer_fn(function() + vim.api.nvim_buf_set_text(0, 0, 0, 0, 0, { '0', 'foo' }) + end, 50) + vim.wait(80) + ]]) + mode = api.nvim_get_mode().mode + eq({ '23' }, fn.getregion(fn.getpos('.'), fn.getpos('v'), { type = mode })) + eq({ 'v', { '0', 'foo1234' } }, { mode, api.nvim_buf_get_lines(0, 0, -1, false) }) + feed('') + + api.nvim_buf_set_lines(0, 0, -1, false, { '1', '2', '3', '4', '5', '6', '7', '8', '9', '10' }) + api.nvim_win_set_cursor(0, { 8, 0 }) + feed('v') + exec_lua([[ + vim.defer_fn(function() vim.api.nvim_buf_set_lines(0, 0, 5, false, {}) end, 50) + vim.wait(80) + ]]) + mode = api.nvim_get_mode().mode + eq({ '8' }, fn.getregion(fn.getpos('.'), fn.getpos('v'), { type = mode })) + eq({ 'v', { '6', '7', '8', '9', '10' } }, { mode, api.nvim_buf_get_lines(0, 0, -1, false) }) + feed('') + end) + describe('when text is being added right at cursor position #22526', function() it('updates the cursor position in NORMAL mode', function() insert([[