fix(decor): highlight without 'hl_eol' bleeds into wrap gaps #41160

Problem:
'linebreak' filler and 'breakindent'/'showbreak' padding are screen
cells with no buffer character behind them, yet a decoration draws over
them whether or not it asked to cover such cells. A highlight bounded to
its text then paints a tail out to the edge of the row, most visible on
inline code spans from plugins. That same highlight already leaves the
cells past the end of a line alone, so it treats identical cells two
different ways.

Solution:
Only a decoration with 'hl_eol' draws the gaps, which is what the flag
already means at the end of a line. A full-width background such as a
fenced code block sets it and still covers them. Classic :syntax has no
such flag and is unchanged.
This commit is contained in:
Freddie Haddad
2026-08-07 02:09:48 -07:00
committed by GitHub
parent a4a544032a
commit a8da01f8e6
5 changed files with 46 additions and 17 deletions

View File

@@ -767,6 +767,7 @@ next_mark:
int new_cur_end = 0;
int attr = 0;
int hl_eol_attr = 0;
int conceal = 0;
schar_T conceal_char = 0;
int conceal_attr = 0;
@@ -789,6 +790,9 @@ next_mark:
if (r->attr_id > 0) {
attr = hl_combine_attr(attr, r->attr_id);
if (r->kind == kDecorKindHighlight && (r->data.sh.flags & kSHHlEol)) {
hl_eol_attr = hl_combine_attr(hl_eol_attr, r->attr_id);
}
}
if (r->kind == kDecorKindHighlight && (r->data.sh.flags & kSHConceal)) {
@@ -852,6 +856,7 @@ next_mark:
state->col_last = col_last;
state->current = attr;
state->current_hl_eol = hl_eol_attr;
state->conceal = conceal;
state->conceal_char = conceal_char;
state->conceal_attr = conceal_attr;

View File

@@ -87,6 +87,7 @@ typedef struct {
int row;
int col_last;
int current;
int current_hl_eol; ///< "current" limited to ranges with "hl_eol"
int eol_col;
int conceal;

View File

@@ -1146,7 +1146,8 @@ int win_line(win_T *wp, linenr_T lnum, int startrow, int endrow, int col_rows, b
int search_attr = 0; // attributes desired by 'hlsearch' or ComplMatchIns
int vcol_save_attr = 0; // saved attr for 'cursorcolumn'
int decor_attr = 0; // attributes desired by syntax and extmarks
int decor_attr_save = 0; // decor_attr saved for gap_decor_attr
int syntax_attr = 0; // syntax-only part of "decor_attr"
int gap_attr_save = 0; // attr for gaps showing no buffer text
bool has_syntax = false; // this buffer has syntax highl.
int folded_attr = 0; // attributes for folded line
int eol_hl_off = 0; // 1 if highlighted char after EOL
@@ -1856,7 +1857,7 @@ int win_line(win_T *wp, linenr_T lnum, int startrow, int endrow, int col_rows, b
// Check if 'breakindent' applies and show it. Like the 'linebreak' filler, attrs must not
// extend into this gap either (see attr_has_line_deco()).
int const gap_decor_attr = attr_has_line_deco(decor_attr_save) ? 0 : decor_attr_save;
int const gap_decor_attr = attr_has_line_deco(gap_attr_save) ? 0 : gap_attr_save;
if (!wp->w_briopt_sbr) {
handle_breakindent(wp, &wlv, gap_decor_attr);
}
@@ -2275,6 +2276,7 @@ int win_line(win_T *wp, linenr_T lnum, int startrow, int endrow, int col_rows, b
ptr++;
decor_attr = 0;
syntax_attr = 0;
if (extra_check) {
const bool no_plain_buffer = (wp->w_s->b_p_spo_flags & kOptSpoFlagNoplainbuffer) != 0;
bool can_spell = !no_plain_buffer;
@@ -2315,6 +2317,7 @@ int win_line(win_T *wp, linenr_T lnum, int startrow, int endrow, int col_rows, b
if (has_decor && v > 0) {
// extmarks take preceedence over syntax.c
syntax_attr = decor_attr;
decor_attr = hl_combine_attr(decor_attr, extmark_attr);
decor_conceal = decor_state.conceal;
can_spell = TRISTATE_TO_BOOL(decor_state.spell, can_spell);
@@ -2397,7 +2400,10 @@ int win_line(win_T *wp, linenr_T lnum, int startrow, int endrow, int col_rows, b
wlv.char_attr);
}
decor_attr_save = decor_attr; // save current attr
// Gaps ('linebreak' filler, 'breakindent'/'showbreak' padding) show no buffer text, so only
// an "hl_eol" decoration draws there.
gap_attr_save = has_decor ? hl_combine_attr(syntax_attr, decor_state.current_hl_eol)
: decor_attr;
// we don't want linebreak to apply for lines that start with
// leading spaces, followed by long letters (since it would add
@@ -2429,6 +2435,9 @@ int win_line(win_T *wp, linenr_T lnum, int startrow, int endrow, int col_rows, b
if (on_last_col || attr_has_line_deco(search_attr)) {
search_attr = 0;
}
if (has_decor) {
decor_attr = gap_attr_save;
}
if (attr_has_line_deco(decor_attr)) {
decor_attr = 0;
}

View File

@@ -330,22 +330,36 @@ describe('listlbr', function()
]])
end)
it('extends a background highlight into the filler for the last word on a line', function()
it('extends a background highlight into the filler only with hl_eol', function()
local screen = Screen.new(20, 5)
screen:add_extra_attr_ids({ [100] = { background = Screen.colors.Red } })
source([[
set wrap linebreak
call setline(1, 'word1.word2verylongwordthatpushesdown')
highlight TestBg guibg=Red ctermbg=Red
let g:ns = nvim_create_namespace('')
]])
-- The pushed-down word is the last thing on the line, so nothing absorbs the width overflow: a
-- background highlight must still extend into the filler, since a solid color looks fine over
-- blank cells (unlike underline/strikethrough, see tests above).
command([[call nvim_buf_add_highlight(0, -1, 'TestBg', 0, 0, -1)]])
-- 'hl_eol' covers cells with no text behind them, so the filler is covered too.
command(
'call nvim_buf_set_extmark(0, g:ns, 0, 0, '
.. "{'end_row': 1, 'hl_group': 'TestBg', 'hl_eol': v:true})"
)
screen:expect([[
{100:^word1. }|
{100:word2verylongwordtha}|
{100:tpushesdown }|
{1:~ }|
|
]])
-- Without it the highlight decorates characters, so it stops where they do, just as an
-- underline already does (see tests above).
command('call nvim_buf_clear_namespace(0, -1, 0, -1)')
command([[call nvim_buf_add_highlight(0, -1, 'TestBg', 0, 0, -1)]])
screen:expect([[
{100:^word1.} |
{100:word2verylongwordtha}|
{100:tpushesdown} |
{1:~ }|
|

View File

@@ -767,7 +767,7 @@ describe('decorations providers', function()
}
end)
it("'hl_eol' and plain highlights extend into 'linebreak'/'breakindent'/'showbreak' gaps", function()
it("'hl_eol' extends into 'linebreak'/'breakindent'/'showbreak' gaps", function()
command('set wrap linebreak breakindent breakindentopt=shift:2')
api.nvim_buf_set_lines(0, 0, -1, false, {
' if vim.g.colors_name == "ferricferric" then',
@@ -794,31 +794,31 @@ describe('decorations providers', function()
|
]])
-- No hl_eol here: these gaps have no real text, but the highlight is still active, not
-- ending, so it must show through regardless.
-- Without 'hl_eol' the highlight decorates characters, so it leaves the gaps alone, the same
-- way it already leaves the cells past the end of the line alone.
api.nvim_buf_set_extmark(0, ns, 0, 0, { id = id, end_row = 1, hl_group = 'DiffAdd' })
screen:expect([[
{100:^ if vim.g.colors_name == }|
{100: }{101:>}{100:"ferricferric" then} |
{100:^ if vim.g.colors_name == } |
{1:>}{100:"ferricferric" then} |
{1:~ }|*5
|
]])
end)
it('breakindent keeps the highlight after a real linebreak word-push', function()
-- The word pushed down by 'linebreak' resets decor_attr (it hides real word text, see listlbr_spec.lua), which must not then leak into
-- 'breakindent's own gap on the row the word lands on.
-- The word pushed down by 'linebreak' resets decor_attr (see listlbr_spec.lua), which must not
-- then leak into 'breakindent's own gap on the row the word lands on.
screen:try_resize(30, 5)
screen:add_extra_attr_ids({ [100] = { background = Screen.colors.LightBlue } })
command('set wrap linebreak breakindent')
local line = ' This is a long indented line of plain text that will wrap nicely.'
api.nvim_buf_set_lines(0, 0, -1, false, { line })
local ns = api.nvim_create_namespace('code_bg2')
api.nvim_buf_set_extmark(0, ns, 0, 0, { end_row = 0, end_col = #line, hl_group = 'DiffAdd' })
api.nvim_buf_set_extmark(0, ns, 0, 0, { end_row = 1, hl_group = 'DiffAdd', hl_eol = true })
screen:expect([[
{100:^ This is a long indented }|
{100: line of plain text that }|
{100: will wrap nicely.} |
{100: will wrap nicely. }|
{1:~ }|
|
]])