fix(ui): highlight bleeds into 'linebreak' filler #41072

Problem:
When 'linebreak' pushes a word entirely to the next screen row, the
filler cells left on the current row keep whatever highlight was set
by the last real character before the break, even when that highlight
should not extend past it (e.g. an underline, which looks broken drawn
over blank cells).

Solution:
Reset decor_attr and area_attr at the filler when their attribute has
an underline, undercurl, strikethrough, or overline; otherwise leave
them, since a plain background or reverse-video highlight looks
correct extending through blank filler cells, regardless of where the
pushed-down word happens to end. search_attr keeps the same check,
plus its pre-existing on_last_col case (its own match ending exactly
here).
This commit is contained in:
Freddie Haddad
2026-08-03 02:20:04 -07:00
committed by GitHub
parent 1f4335fe72
commit 9ff302d0ca
2 changed files with 103 additions and 6 deletions

View File

@@ -800,6 +800,16 @@ static void draw_statuscol(win_T *wp, winlinevars_T *wlv, int col_rows, statusco
draw_col_fill(wlv, schar_from_ascii(' '), stcp->width - width, cur_attr);
}
/// Whether "attr" draws an underline, undercurl, strikethrough, or overline: a line tied to text
/// glyphs that looks broken drawn over blank filler cells, unlike a plain background/reverse-video
/// highlight.
static bool attr_has_line_deco(int attr)
{
HlAttrs ae = syn_attr2entry(attr);
int32_t const mask = HL_UNDERLINE_MASK | HL_STRIKETHROUGH | HL_OVERLINE;
return (ae.rgb_ae_attr & mask) != 0 || (ae.cterm_ae_attr & mask) != 0;
}
static void handle_breakindent(win_T *wp, winlinevars_T *wlv)
{
// draw 'breakindent': indent wrapped text accordingly
@@ -2403,11 +2413,21 @@ int win_line(win_T *wp, linenr_T lnum, int startrow, int endrow, int col_rows, b
wlv.n_extra = win_charsize(cstype, wlv.vcol, p, utf_ptr2CharInfo(p).value,
&csarg).width - 1;
if (on_last_col && mb_c != TAB) {
// Do not continue search/match highlighting over the
// line break, but for TABs the highlighting should
// include the complete width of the character
search_attr = 0;
// Do not bleed an underline/strikethrough/overline into the filler for the pushed-down
// word (TABs keep their own full-width highlight): drawn over blank cells, they look like
// a broken line. A plain background/reverse-video highlight does not have this problem,
// so leave it be. search_attr also resets when the search/match's own span ends exactly
// here (on_last_col), matching its pre-existing semantics.
if (mb_c != TAB) {
if (on_last_col || attr_has_line_deco(search_attr)) {
search_attr = 0;
}
if (attr_has_line_deco(decor_attr)) {
decor_attr = 0;
}
if (attr_has_line_deco(area_attr)) {
area_attr = 0;
}
}
if (mb_c == TAB && wlv.n_extra + wlv.col > view_width) {

View File

@@ -6,7 +6,7 @@ local Screen = require('test.functional.ui.screen')
local describe, it, before_each = t.describe, t.it, t.before_each
local feed, insert, source = n.feed, n.insert, n.source
local clear, feed_command, expect = n.clear, n.feed_command, n.expect
local clear, command, feed_command, expect = n.clear, n.command, n.feed_command, n.expect
describe('listlbr', function()
before_each(clear)
@@ -274,4 +274,81 @@ describe('listlbr', function()
{9:E490: No fold found} |
]])
end)
it('does not bleed match/extmark highlight into the filler before a wrapped word', function()
local screen = Screen.new(60, 3)
screen:add_extra_attr_ids({ [100] = { underline = true } })
source([[
set wrap linebreak
call setline(1, '[license-commit]: https://github.com/neovim/neovim/commit/b17d9691a24099c9210289f16afb1a498a89d803')
highlight TestUL gui=underline cterm=underline
]])
-- Highlight extends past the break point: must not bleed into the filler before the
-- pushed-down word, whether applied via matchadd() (search_attr) or an extmark (decor_attr).
local expected = [[
^[license-commit]: {100:https://github.com/neovim/neovim/commit/} |
{100:b17d9691a24099c9210289f16afb1a498a89d803} |
|
]]
command([[call matchadd('TestUL', 'https://.*$')]])
screen:expect(expected)
-- Confirm the highlight actually toggles off before re-checking via the extmark path below,
-- so the second `expect(expected)` proves that path redraws correctly instead of coasting on
-- leftover state from matchadd().
command('call clearmatches()')
screen:expect([[
^[license-commit]: https://github.com/neovim/neovim/commit/ |
b17d9691a24099c9210289f16afb1a498a89d803 |
|
]])
command([[call nvim_buf_add_highlight(0, -1, 'TestUL', 0, 18, -1)]])
screen:expect(expected)
end)
it('extends a to-end-of-line syntax highlight through the filler', function()
local screen = Screen.new(30, 5)
screen:add_extra_attr_ids({ [100] = { reverse = true } })
source([[
set wrap linebreak
call setline(1, 'section.heading:' . repeat(' ', 40) . 'END')
syntax match TestBar /^.*$/
highlight TestBar gui=reverse cterm=reverse
]])
-- Unlike the tests above: the filler here is real trailing padding covered by the same 'to end
-- of line' match, so it must keep the highlight instead of going blank.
screen:expect([[
{100:^section. }|
{100:heading: }|
{100: END} |
{1:~ }|
|
]])
end)
it('extends a background highlight into the filler for the last word on a line', 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
]])
-- 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)]])
screen:expect([[
{100:^word1. }|
{100:word2verylongwordtha}|
{100:tpushesdown} |
{1:~ }|
|
]])
end)
end)