From 9ff302d0ca260ffb06b1f1815ac4db1c63d1a9b3 Mon Sep 17 00:00:00 2001 From: Freddie Haddad <6127369+freddiehaddad@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:20:04 -0700 Subject: [PATCH] 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). --- src/nvim/drawline.c | 30 ++++++++-- test/functional/legacy/listlbr_spec.lua | 79 ++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/nvim/drawline.c b/src/nvim/drawline.c index 8f4ba5a9a9..e437611c77 100644 --- a/src/nvim/drawline.c +++ b/src/nvim/drawline.c @@ -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) { diff --git a/test/functional/legacy/listlbr_spec.lua b/test/functional/legacy/listlbr_spec.lua index c5dedd54cf..a4dfad6cf7 100644 --- a/test/functional/legacy/listlbr_spec.lua +++ b/test/functional/legacy/listlbr_spec.lua @@ -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)