Commit: patch 9.2.1126: virtual text breaks cursor movement when 've' is set

2 views
Skip to first unread message

Christian Brabandt

unread,
Sep 24, 2026, 2:30:18 PM (yesterday) Sep 24
to vim...@googlegroups.com
patch 9.2.1126: virtual text breaks cursor movement when 've' is set

Commit: https://github.com/vim/vim/commit/c7784d74a1b666e7699b0f72847a3cfca77205cd
Author: Yamagi <yam...@yamagi.org>
Date: Thu Sep 24 18:17:50 2026 +0000

patch 9.2.1126: virtual text breaks cursor movement when 've' is set

Problem: Virtual text breaks cursor movement when 'virtualedit' is set
to "all" (Yamagi)
Solution: Count the screen space occupied by virtual text when
calculating the cursor position (Yamagi).

getvcol() counted the width of virtual text above a line in the end and
cursor column of the line's first character, but not in its start
column. Now all three include it, which also corrects what screenpos()
and virtcol([lnum, col], 1) returns for that character with any
'virtualedit': the row of the buffer text instead of the row of the
virtual text, and its start column instead of column 1. Callers wanting
the columns of the buffer text only pass the new GETVCOL_NO_ABOVE flag.

Whether getvcol() sets "w_virtcol_first_char" was decided by comparing
its "cursor" argument with "&wp->w_virtcol". That never matched while
virtual_active() is true, which is not only the case with "all", but
also for a blockwise Visual selection, in Insert mode and while an
operator is running. The new GETVCOL_FOR_VIRTCOL flag says what the
value is used for instead.

fixes: #14049
closes: #21336

Assisted-By: Claude Opus 5
Signed-off-by: Yamagi <yam...@yamagi.org>
Signed-off-by: Christian Brabandt <c...@256bit.org>

diff --git a/runtime/doc/builtin.txt b/runtime/doc/builtin.txt
index c72a7f2ba..058821ff4 100644
--- a/runtime/doc/builtin.txt
+++ b/runtime/doc/builtin.txt
@@ -9546,6 +9546,9 @@ screenpos({winid}, {lnum}, {col}) *screenpos()*
as if 'conceallevel' is zero. You can set the cursor to the
right position and use |screencol()| to get the value with
|conceal| taken into account.
+ |virtual-text| above the line occupies its own screen line and
+ is counted: the first character of the line is then on the
+ screen line below it.
If the position is in a closed fold the screen position of the
first character is returned, {col} is not used.
Returns an empty Dict if {winid} is invalid.
@@ -12743,6 +12746,10 @@ virtcol({expr} [, {list} [, {winid}]]) *virtcol()*
beyond the end of the line can be returned. Also see
'virtualedit'

+ |virtual-text| above the line occupies its own screen line and
+ is counted like a wrapped line: with a window 80 columns wide
+ the first character of the line is then in virtual column 81.
+
If {list} is present and non-zero then virtcol() returns a
List with the first and last screen position occupied by the
character.
diff --git a/runtime/doc/version9.txt b/runtime/doc/version9.txt
index 1eb5de422..e7ff81a2c 100644
--- a/runtime/doc/version9.txt
+++ b/runtime/doc/version9.txt
@@ -1,4 +1,4 @@
-*version9.txt* For Vim version 9.2. Last change: 2026 Sep 08
+*version9.txt* For Vim version 9.2. Last change: 2026 Sep 24


VIM REFERENCE MANUAL by Bram Moolenaar
@@ -52718,6 +52718,7 @@ Changed ~
autocommand.
- The highlighting groups |hl-CursorLineFold| and |hl-CursorLineSign| are
always used when 'cursorline' is set.
+- Virtual text above a line is now counted by |screenpos()| and |virtcol()|.

*added-9.3*
Added ~
diff --git a/src/charset.c b/src/charset.c
index c8d9552d1..00da33e27 100644
--- a/src/charset.c
+++ b/src/charset.c
@@ -909,6 +909,16 @@ linetabsize_no_outer(win_T *wp, linenr_T lnum)
#endif
}

+/*
+ * Like linetabsize_no_outer(), but counts the size of 'listchars' "eol".
+ */
+ int
+linetabsize_no_outer_eol(win_T *wp, linenr_T lnum)
+{
+ return linetabsize_no_outer(wp, lnum)
+ + ((wp->w_p_list && wp->w_lcs_chars.eol != NUL) ? 1 : 0);
+}
+
/*
* Return TRUE when win_lbr_chartabsize() does nothing more than
* win_nolbr_chartabsize(): no 'linebreak', 'breakindent', 'showbreak' and no
@@ -1411,11 +1421,17 @@ win_lbr_chartabsize(
}
else
cells = vim_strsize(p);
- cts->cts_cur_text_width += cells;
if (tp->tp_flags & TP_FLAG_ALIGN_ABOVE)
+ {
cts->cts_first_char += cells;
+ if (!cts->cts_no_above)
+ cts->cts_cur_text_width += cells;
+ }
else
+ {
+ cts->cts_cur_text_width += cells;
size += cells;
+ }
cts->cts_start_incl = tp->tp_flags & TP_FLAG_START_INCL;
# ifdef FEAT_LINEBREAK
if (*s == TAB)
@@ -1609,11 +1625,13 @@ win_lbr_chartabsize(
*tailp = size - size_before_lbr;

# ifdef FEAT_PROP_POPUP
- if (cts->cts_first_char > 0)
+ if (cts->cts_first_char > 0 && !cts->cts_no_above)
+ {
// Remember the width for the size of a Tab later in the line. Use
// assignment, this may be called more than once for a character.
cts->cts_above_width = cts->cts_first_char;
- size += cts->cts_first_char;
+ size += cts->cts_first_char;
+ }
# endif
# endif
return size;
@@ -1724,6 +1742,9 @@ getvcol(

init_chartabsize_arg(&cts, wp, pos->lnum, 0, line, line);
cts.cts_max_head_vcol = -1;
+#ifdef FEAT_PROP_POPUP
+ cts.cts_no_above = true;
+#endif

/*
* This function is used very often, do some speed optimizations.
@@ -1801,6 +1822,19 @@ getvcol(
head = 0;
tail = 0;
incr = win_lbr_chartabsize(&cts, &head, &tail);
+#ifdef FEAT_PROP_POPUP
+ if (cts.cts_ptr == cts.cts_line)
+ {
+ if (flags & GETVCOL_FOR_VIRTCOL)
+ // do not count the virtual text above for w_curswant
+ wp->w_virtcol_first_char = cts.cts_first_char;
+ if ((flags & GETVCOL_NO_ABOVE) == 0)
+ {
+ cts.cts_vcol += cts.cts_first_char;
+ cts.cts_above_width = cts.cts_first_char;
+ }
+ }
+#endif
// make sure we don't go past the end of the line
if (*cts.cts_ptr == NUL)
{
@@ -1811,11 +1845,6 @@ getvcol(
#endif
break;
}
-#ifdef FEAT_PROP_POPUP
- if (cursor == &wp->w_virtcol && cts.cts_ptr == cts.cts_line)
- // do not count the virtual text above for w_curswant
- wp->w_virtcol_first_char = cts.cts_first_char;
-#endif

char_u *next_ptr = cts.cts_ptr + (*mb_ptr2len)(cts.cts_ptr);
if (next_ptr - line > pos->col) // character at pos->col
@@ -1855,9 +1884,6 @@ getvcol(
if (((State & MODE_INSERT) == 0 || cts.cts_start_incl) && !on_NUL)
// cursor is after inserted text, unless on the NUL
vcol += cts.cts_cur_text_width;
- else
- // insertion also happens after the "above" virtual text
- vcol += cts.cts_first_char;
#endif
*cursor = vcol + head; // cursor at start
}
diff --git a/src/misc2.c b/src/misc2.c
index a33591cc4..cc20a81ef 100644
--- a/src/misc2.c
+++ b/src/misc2.c
@@ -44,7 +44,7 @@ getviscol(void)
{
colnr_T x;

- getvvcol(curwin, &curwin->w_cursor, &x, NULL, NULL, 0);
+ getvvcol(curwin, &curwin->w_cursor, &x, NULL, NULL, GETVCOL_NO_ABOVE);
return (int)x;
}

@@ -78,7 +78,7 @@ getviscol2(colnr_T col, colnr_T coladd)
pos.lnum = curwin->w_cursor.lnum;
pos.col = col;
pos.coladd = coladd;
- getvvcol(curwin, &pos, &x, NULL, NULL, 0);
+ getvvcol(curwin, &pos, &x, NULL, NULL, GETVCOL_NO_ABOVE);
return (int)x;
}

@@ -147,7 +147,8 @@ coladvance2(

if ((addspaces || finetune) && !VIsual_active)
{
- curwin->w_curswant = linetabsize(curwin, pos->lnum) + one_more;
+ curwin->w_curswant = linetabsize_no_outer(curwin, pos->lnum)
+ + one_more;
if (curwin->w_curswant > 0)
--curwin->w_curswant;
}
@@ -163,7 +164,7 @@ coladvance2(
&& wcol >= (colnr_T)width
&& width > 0)
{
- csize = linetabsize_eol(curwin, pos->lnum);
+ csize = linetabsize_no_outer_eol(curwin, pos->lnum);
if (csize > 0)
csize--;

@@ -179,11 +180,11 @@ coladvance2(
}

init_chartabsize_arg(&cts, curwin, pos->lnum, 0, line, line);
- while (cts.cts_vcol <= wcol && *cts.cts_ptr != NUL)
- {
#ifdef FEAT_PROP_POPUP
- int at_start = cts.cts_ptr == cts.cts_line;
+ cts.cts_no_above = true;
#endif
+ while (cts.cts_vcol <= wcol && *cts.cts_ptr != NUL)
+ {
// Count a tab for what it's worth (if list mode not on)
#ifdef FEAT_LINEBREAK
csize = win_lbr_chartabsize(&cts, &head, NULL);
@@ -192,11 +193,6 @@ coladvance2(
csize = lbr_chartabsize_adv(&cts);
#endif
cts.cts_vcol += csize;
-#ifdef FEAT_PROP_POPUP
- if (at_start)
- // do not count the columns for virtual text above
- cts.cts_vcol -= cts.cts_first_char;
-#endif
}
col = cts.cts_vcol;
idx = (int)(cts.cts_ptr - line);
diff --git a/src/move.c b/src/move.c
index 2b978fe40..07aa86629 100644
--- a/src/move.c
+++ b/src/move.c
@@ -1112,7 +1112,8 @@ validate_virtcol_win(win_T *wp)
#ifdef FEAT_PROP_POPUP
wp->w_virtcol_first_char = 0;
#endif
- getvvcol(wp, &wp->w_cursor, NULL, &(wp->w_virtcol), NULL, 0);
+ getvvcol(wp, &wp->w_cursor, NULL, &(wp->w_virtcol), NULL,
+ GETVCOL_FOR_VIRTCOL);
#ifdef FEAT_SYN_HL
redraw_for_cursorcolumn(wp);
#endif
@@ -1273,8 +1274,8 @@ curs_columns(
startcol = curwin->w_virtcol = endcol = curwin->w_leftcol;
else
#endif
- getvvcol(curwin, &curwin->w_cursor,
- &startcol, &(curwin->w_virtcol), &endcol, 0);
+ getvvcol(curwin, &curwin->w_cursor, &startcol,
+ &(curwin->w_virtcol), &endcol, GETVCOL_FOR_VIRTCOL);

// remove '$' from change command when cursor moves onto it
if (startcol > dollar_vcol)
diff --git a/src/proto/charset.pro b/src/proto/charset.pro
index 68ac1554a..3893afc7a 100644
--- a/src/proto/charset.pro
+++ b/src/proto/charset.pro
@@ -24,6 +24,7 @@ int win_linetabsize(win_T *wp, linenr_T lnum, char_u *line, colnr_T len);
int linetabsize(win_T *wp, linenr_T lnum);
int linetabsize_eol(win_T *wp, linenr_T lnum);
int linetabsize_no_outer(win_T *wp, linenr_T lnum);
+int linetabsize_no_outer_eol(win_T *wp, linenr_T lnum);
void win_linetabsize_cts(chartabsize_T *cts, colnr_T len);
int vim_isIDc(int c);
int vim_isNormalIDc(int c);
diff --git a/src/structs.h b/src/structs.h
index df16f5536..613a9ce47 100644
--- a/src/structs.h
+++ b/src/structs.h
@@ -5375,6 +5375,8 @@ typedef struct {
int cts_first_char; // width text props above the line
int cts_above_width; // width of text props above the line,
// kept for the whole line
+ bool cts_no_above; // do not count the width of text props
+ // above the line
int cts_with_trailing; // include size of trailing props with
// last character
int cts_start_incl; // prop has true "start_incl" arg
diff --git a/src/testdir/test_textprop.vim b/src/testdir/test_textprop.vim
index 86437354b..59ae9c58d 100644
--- a/src/testdir/test_textprop.vim
+++ b/src/testdir/test_textprop.vim
@@ -3361,6 +3361,169 @@ func Test_prop_with_text_above_empty()
call StopVimInTerminal(buf)
endfunc

+func Test_prop_with_text_above_virtualedit()
+ call NewWindow(10, 40)
+ setlocal virtualedit=all
+ call setline(1, ['Test', 'Test', 'Test', 'Test'])
+ call prop_type_add('test', #{highlight: 'Normal'})
+ call prop_add(2, 0, #{type: 'test', text: 'Example', text_align: 'above'})
+ redraw
+
+ call cursor(2, 1)
+ call assert_equal([0, 2, 1, 0, 1], getcurpos())
+ call assert_equal(41, virtcol('.'))
+ for col in range(2, 5)
+ normal! l
+ call assert_equal([0, 2, col, 0, col], getcurpos())
+ call assert_equal(40 + col, virtcol('.'))
+ endfor
+
+ normal! l
+ call assert_equal([0, 2, 5, 1, 6], getcurpos())
+ call assert_equal(46, virtcol('.'))
+ normal! h
+ call assert_equal([0, 2, 5, 0, 5], getcurpos())
+ call assert_equal(45, virtcol('.'))
+
+ normal! ^
+ call assert_equal([0, 2, 1, 0, 1], getcurpos())
+ call assert_equal(41, virtcol('.'))
+
+ call cursor(2, 3)
+ normal! j
+ call assert_equal([0, 3, 3, 0, 3], getcurpos())
+ normal! k
+ call assert_equal([0, 2, 3, 0, 3], getcurpos())
+
+ call setline(2, " abc")
+ call prop_add(2, 0, #{type: 'test', text: 'Example', text_align: 'above'})
+ call cursor(2, 1)
+ normal! 3l
+ call assert_equal([0, 2, 1, 3, 4], getcurpos())
+ call assert_equal(44, virtcol('.'))
+
+ call setline(2, '')
+ call prop_add(2, 0, #{type: 'test', text: 'Example', text_align: 'above'})
+ call cursor(2, 1)
+ call assert_equal([0, 2, 1, 0, 1], getcurpos())
+ call assert_equal(41, virtcol('.'))
+ normal! j
+ call assert_equal([0, 3, 1, 0, 1], getcurpos())
+ call cursor(2, 1)
+ normal! l
+ call assert_equal([0, 2, 1, 1, 2], getcurpos())
+ call assert_equal(42, virtcol('.'))
+
+ call prop_type_delete('test')
+ bwipe!
+endfunc
+
+func Test_prop_with_text_below_virtualedit()
+ call NewWindow(10, 40)
+ setlocal virtualedit=all
+ call setline(1, ['Test', 'Test', 'Test', 'Test'])
+ call prop_type_add('test', #{highlight: 'Normal'})
+ call prop_add(2, 0, #{type: 'test', text: 'Example', text_align: 'below'})
+ redraw
+
+ call cursor(2, 1)
+ normal! 4l
+ call assert_equal([0, 2, 5, 0, 5], getcurpos())
+ call assert_equal(5, virtcol('.'))
+ normal! 100l
+ call assert_equal([0, 2, 5, 35, 40], getcurpos())
+ call assert_equal(40, virtcol('.'))
+
+ normal! $
+ call assert_equal([2, 4, 0], getcurpos()[1 : 3])
+ call assert_equal(4, virtcol('.'))
+
+ call cursor(2, 3)
+ normal! j
+ call assert_equal([0, 3, 3, 0, 3], getcurpos())
+ normal! k
+ call assert_equal([0, 2, 3, 0, 3], getcurpos())
+
+ call setline(2, repeat('x', 45))
+ call prop_add(2, 0, #{type: 'test', text: 'Example', text_align: 'below'})
+ call cursor(2, 1)
+ normal! 200l
+ call assert_equal([0, 2, 46, 34, 80], getcurpos())
+ call assert_equal(80, virtcol('.'))
+
+ call setline(2, '')
+ call prop_add(2, 0, #{type: 'test', text: 'Example', text_align: 'below'})
+ call cursor(2, 1)
+ normal! 100l
+ call assert_equal([0, 2, 1, 39, 40], getcurpos())
+ call assert_equal(40, virtcol('.'))
+
+ call prop_type_delete('test')
+ bwipe!
+endfunc
+
+func Test_prop_with_text_virtualedit_curswant()
+ call NewWindow(10, 40)
+ setlocal virtualedit=all
+ call prop_type_add('test', #{highlight: 'Normal'})
+
+ for align in ['none', 'above', 'below', 'after', 'right']
+ call setline(1, ['test', 'test', 'test', 'test'])
+ if align != 'none'
+ call prop_add(2, 0, #{type: 'test', text: 'Example', text_align: align})
+ endif
+ redraw
+
+ call cursor(2, 1)
+ normal! $
+ call assert_equal([0, 2, 4, 0, 4], getcurpos(), align)
+ normal! j
+ call assert_equal([0, 3, 4, 0, 4], getcurpos(), align)
+ call assert_equal(4, virtcol('.'), align)
+ normal! k
+ call assert_equal([0, 2, 4, 0, 4], getcurpos(), align)
+ endfor
+
+ call setline(1, ['test', 'test', 'test', 'test'])
+ call prop_add(2, 3, #{type: 'test', text: 'Example'})
+ redraw
+ call cursor(2, 1)
+ normal! $
+ call assert_equal([0, 2, 4, 0, 11], getcurpos())
+ call assert_equal(11, virtcol('.'))
+ normal! j
+ call assert_equal([0, 3, 5, 6, 11], getcurpos())
+ call assert_equal(11, virtcol('.'))
+
+ call prop_type_delete('test')
+ bwipe!
+endfunc
+
+func Test_prop_with_text_above_screenpos()
+ call NewWindow(10, 40)
+ call setline(1, ['one', 'two', 'three'])
+ call prop_type_add('test', #{highlight: 'Normal'})
+ call prop_add(2, 0, #{type: 'test', text: 'ABOVE', text_align: 'above'})
+ redraw
+
+ let winid = win_getid()
+ call assert_equal(#{row: 1, col: 1, endcol: 1, curscol: 1},
+ \ screenpos(winid, 1, 1))
+ call assert_equal(#{row: 3, col: 1, endcol: 1, curscol: 1},
+ \ screenpos(winid, 2, 1))
+ call assert_equal(#{row: 3, col: 2, endcol: 2, curscol: 2},
+ \ screenpos(winid, 2, 2))
+ call assert_equal(#{row: 4, col: 1, endcol: 1, curscol: 1},
+ \ screenpos(winid, 3, 1))
+
+ call assert_equal([1, 1], virtcol([1, 1], 1))
+ call assert_equal([41, 41], virtcol([2, 1], 1))
+ call assert_equal([42, 42], virtcol([2, 2], 1))
+
+ call prop_type_delete('test')
+ bwipe!
+endfunc
+
func Test_prop_with_text_below_after_match()
CheckScreendump
CheckRunVimInTerminal
diff --git a/src/version.c b/src/version.c
index 85bdcb0af..0e3632046 100644
--- a/src/version.c
+++ b/src/version.c
@@ -763,6 +763,8 @@ static char *(features[]) =

static int included_patches[] =
{ /* Add new patch number below this line */
+/**/
+ 1126,
/**/
1125,
/**/
diff --git a/src/vim.h b/src/vim.h
index b59d97b95..8879e26b6 100644
--- a/src/vim.h
+++ b/src/vim.h
@@ -3153,6 +3153,8 @@ long elapsed(DWORD start_tick);

// Flags used by getvcol()
#define GETVCOL_END_EXCL_LBR 1
+#define GETVCOL_NO_ABOVE 2 // exclude virtual text above the line
+#define GETVCOL_FOR_VIRTCOL 4 // value is used for "w_virtcol"

// Used by expand_env_esc() callers that feed the result to
// wildcard expansion, so that such characters embedded in
Reply all
Reply to author
Forward
0 new messages