From a71a41bc9c46ad32f4ccf48c9abf0c39bad46396 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Fri, 29 May 2026 10:42:12 +0200 Subject: [PATCH] fix(inline): derive unified `diff1_inline` char highlight from `DiffText` Changed chars on a paired row were difficult to distinguish when a colourscheme gave `DiffAdd` and `DiffChange` near-identical backgrounds (e.g., `tokyonight`). Adds a `DiffviewDiffTextInline` group derived from `DiffText`, falling back to `DiffAdd` when `DiffText` is unset; overleaf keeps `DiffviewDiffAddInline`. Restores the `DiffText` source used previously while keeping the `fg`, so tree-sitter syntax foreground still composes through the overlay. Drops the paired-row backdrop to priority 99 (one below tree-sitter's default 100) so syntax `fg` wins ties on the row. --- TIPS.md | 35 ++- doc/diffview.txt | 38 ++- lua/diffview/hl.lua | 122 +++++--- lua/diffview/scene/inline_diff.lua | 170 ++++++++-- .../tests/functional/inline_diff_spec.lua | 296 +++++++++++++++++- 5 files changed, 554 insertions(+), 107 deletions(-) diff --git a/TIPS.md b/TIPS.md index 3471352a..367b187d 100644 --- a/TIPS.md +++ b/TIPS.md @@ -98,22 +98,29 @@ Common questions, useful patterns, and known compatibility issues. }, }) ``` - - Customise the look with `:hi DiffviewDiffDeleteInline gui=...`. + - Customise the strikethrough via `DiffviewDiffDeleteInline` (see the + next entry on overriding inline groups). - **Customise inline char-level highlights:** - - In the `diff1_inline` layout, changed characters on paired - modification rows use the `DiffviewDiffAddInline` highlight group, - and inline strikethrough deletions in the "overleaf" style use - `DiffviewDiffDeleteInline`. The defaults derive their backgrounds - from `DiffviewDiffAdd` and `DiffviewDiffDelete` respectively. If - your colourscheme defines `DiffAdd.bg` and `DiffChange.bg` with - similar tints, char-level changes can blend into the paired-row - `DiffviewDiffChange` backdrop. Override the inline group to taste, - e.g., to use the colourscheme's `DiffText` as the char-level - background: + - In the `diff1_inline` layout, changed characters use these groups: + the unified style highlights them with `DiffviewDiffTextInline`, + while the "overleaf" style uses `DiffviewDiffAddInline` for added + chars and `DiffviewDiffDeleteInline` for the strikethrough + deletions. Their backgrounds derive by default from `DiffText`, + `DiffviewDiffAdd`, and `DiffviewDiffDelete` respectively. The + unified group tracks `DiffText` (as the built-in side-by-side diff + does), so changes stay visible against the paired-row + `DiffviewDiffChange` backdrop even when your colourscheme gives + `DiffAdd` and `DiffChange` similar tints (e.g. tokyonight); it falls + back to `DiffAdd` for schemes that leave `DiffText` unset. + - These groups are re-derived on every colourscheme change, so set + overrides from a `ColorScheme` autocmd rather than once at startup + (a plain `:hi` is reapplied over on the next change): ```lua - vim.api.nvim_set_hl(0, "DiffviewDiffAddInline", { link = "DiffText" }) - -- Or with explicit colours: - vim.api.nvim_set_hl(0, "DiffviewDiffAddInline", { bg = "#3a4a3a" }) + vim.api.nvim_create_autocmd("ColorScheme", { + callback = function() + vim.api.nvim_set_hl(0, "DiffviewDiffTextInline", { bg = "#3a4a3a" }) + end, + }) ``` - **Better diff display (changes shown as add+delete instead of modification):** - Set Neovim's `diffopt` to use a better algorithm: diff --git a/doc/diffview.txt b/doc/diffview.txt index 95fa1cca..27dd10b4 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -810,8 +810,8 @@ view.x.layout *diffview-config-view.x.layout* A single-window unified ("inline") diff. The new-side content is shown in one buffer; deletions are rendered as virtual - lines and intra-line added chars are highlighted with - `DiffviewDiffAddInline`. Not applicable to merge conflicts. + lines and intra-line changed chars are highlighted with + `DiffviewDiffTextInline`. Not applicable to merge conflicts. ┌──────────────┐ │ │ @@ -1019,8 +1019,8 @@ view.inline *diffview-config-view.inline* • `"unified"`: proper unified diff (git/GitHub style) — old lines for each hunk (pure deletion or modification) shown above as virtual lines; modified new lines get a - |hl-DiffChange| line background with added chars - highlighted via `DiffviewDiffAddInline`. + |hl-DiffChange| line background with changed chars + highlighted via `DiffviewDiffTextInline`. • `"overleaf"`: Overleaf-editor style. Deleted chars on modified lines are rendered inline as strikethrough virtual text where they used to be; added chars get @@ -1032,16 +1032,30 @@ view.inline *diffview-config-view.inline* Highlight groups used: ~ • |hl-DiffAdd|, |hl-DiffChange|, |hl-DiffDelete| via - their `Diffview*` variants. - • `DiffviewDiffAddInline` for the added char ranges - (pure adds or the added side of a modification). - Default: `DiffviewDiffAdd`'s `bg` with no `fg`, so - tree-sitter syntax foreground composes through the - char-level extmark instead of being overridden. - Override with |:hi| to customise. + their `Diffview*` variants, used as line-level + backgrounds. A wholly added line (and the surplus + new lines of an uneven modification) gets a + full-line `DiffviewDiffAdd` with no char-level + overlay; the inline groups below apply only to the + differing characters on a paired/modified row. + • `DiffviewDiffTextInline` for the changed char ranges + on paired ("unified") modifications. Default: + `DiffText`'s `bg` with no `fg`, matching the + side-by-side diff so the change contrasts with the + |hl-DiffChange| backdrop even when a colourscheme + gives `DiffAdd` and `DiffChange` similar backgrounds. + Falls back to `DiffAdd`'s `bg` when the colourscheme + leaves `DiffText` unset. + • `DiffviewDiffAddInline` for the added char ranges in + "overleaf" style. Default: `DiffviewDiffAdd`'s `bg`. + Both inline groups drop `fg` so tree-sitter syntax + foreground composes through the char-level extmark + instead of being overridden. Override either with a + |ColorScheme| autocmd (a plain |:hi| is reapplied on + the next colourscheme change). • `DiffviewDiffDeleteInline` for overleaf-mode deletions (default: `DiffDelete` colours + - `strikethrough`; override with |:hi| to customise). + `strikethrough`; override as above). {deletion_highlight} ("text"|"full_width"|"hanging") How far the |hl-DiffDelete| background extends on the diff --git a/lua/diffview/hl.lua b/lua/diffview/hl.lua index 0e70fbc1..2f538fdc 100644 --- a/lua/diffview/hl.lua +++ b/lua/diffview/hl.lua @@ -441,6 +441,71 @@ M.hl_links = { DiffText = "DiffText", } +-- Compute the inline-overlay `bg` and kept style attrs from `group`'s +-- colours. `bg` comes from `group`; when the group uses `reverse`/`standout` +-- the visible bg is actually its `fg` (the swap moves it there), so read that +-- instead -- otherwise reverse-only colourschemes lose the bg entirely once +-- we strip `reverse`. Returns `bg` ("NONE" when the group has no usable +-- background) and the comma-joined style string ("NONE" when empty), with +-- `reverse`/`standout` dropped (see `derive_inline_hl` for why). +---@param group string +---@return string bg +---@return string style +local function inline_bg_and_style(group) + local kept_attrs = {} + local is_reversed = false + + for _, attr in ipairs(vim.split(M.get_style(group) or "", ",")) do + if attr == "reverse" or attr == "standout" then + is_reversed = true + elseif attr ~= "" then + kept_attrs[#kept_attrs + 1] = attr + end + end + + local bg + if is_reversed then + bg = M.get_fg(group) or M.get_bg(group) or "NONE" + else + bg = M.get_bg(group) or "NONE" + end + + return bg, #kept_attrs > 0 and table.concat(kept_attrs, ",") or "NONE" +end + +-- Derive an inline char-range highlight `target` from `source`'s colours, +-- used by the `diff1_inline` layout to paint changed/added characters on top +-- of a paired row (priority-200 extmark). `fg` is dropped so tree-sitter +-- foreground composes through the extmark instead of being stomped; `reverse` +-- and `standout` are stripped for the same reason (they swap fg/bg at render +-- time, which would let the `source` bg paint over the syntax `fg`). +-- +-- When `source` yields no usable background (e.g. a colourscheme that defines +-- `DiffAdd`/`DiffChange` but leaves `DiffText` unset), derive from `fallback` +-- instead so the overlay never regresses to invisible. +-- +-- Set with `explicit` rather than `default`: a `default` highlight is a no-op +-- once the group exists, which would pin whatever value was derived at the +-- first `setup()` (e.g. from a built-in `DiffAdd` active before the user's +-- colourscheme loaded) and never refresh it on later `ColorScheme` events. +-- `explicit` rebuilds the group from scratch each call so it always tracks +-- the active colourscheme. +---@param source string Source highlight group to derive from. +---@param target string Target highlight group to (re)define. +---@param fallback? string Source used when `source` has no usable background. +local function derive_inline_hl(source, target, fallback) + local bg, style = inline_bg_and_style(source) + if bg == "NONE" and fallback then + bg, style = inline_bg_and_style(fallback) + end + + M.hi(target, { + bg = bg, + style = style, + explicit = true, + }) +end + function M.update_diff_hl() local fg = M.get_fg("DiffDelete", true) or "NONE" local bg = M.get_bg("DiffDelete", true) or "NONE" @@ -461,10 +526,9 @@ function M.update_diff_hl() -- is honoured. Runs AFTER the relink above so the final state is read. local del_fg = M.get_fg("DiffviewDiffDelete") or "NONE" local del_bg = M.get_bg("DiffviewDiffDelete") or "NONE" - -- Use `explicit` (not `default`): a `default` highlight is a no-op once the - -- group exists, so the colours derived at the first `setup()` would be pinned - -- and never refresh on later `ColorScheme` events. `explicit` rebuilds the - -- group from scratch each call so it always tracks the active colourscheme. + -- `explicit` (not `default`) so the group is rebuilt on every `ColorScheme` + -- rather than pinned to the value derived at the first `setup()`; see + -- `derive_inline_hl` for the full rationale. M.hi("DiffviewDiffDeleteInline", { fg = del_fg, bg = del_bg, @@ -472,40 +536,22 @@ function M.update_diff_hl() explicit = true, }) - -- `diff1_inline` highlight for inserted char ranges (pure adds or the - -- added side of a modification). Derives `bg` from `DiffviewDiffAdd` - -- (not `DiffviewDiffText`) so inserted bytes share the addition bg at - -- any granularity, matching `diff2`. `fg` is dropped so tree-sitter - -- foreground composes through the priority-200 extmark instead of - -- being stomped. `reverse` and `standout` are stripped from the - -- inherited style for the same reason: they swap fg/bg at render - -- time, so leaving them in would let the addition `bg` paint over - -- the syntax `fg`. When the source uses one of those attrs, the - -- visible bg is actually the source `fg` (the swap moves it there), - -- so use that instead — otherwise reverse-only colourschemes lose - -- the addition bg entirely after we strip `reverse`. - local add_style_attrs = {} - local add_is_reversed = false - for _, attr in ipairs(vim.split(M.get_style("DiffviewDiffAdd") or "", ",")) do - if attr == "reverse" or attr == "standout" then - add_is_reversed = true - elseif attr ~= "" then - add_style_attrs[#add_style_attrs + 1] = attr - end - end - local add_bg - if add_is_reversed then - add_bg = M.get_fg("DiffviewDiffAdd") or M.get_bg("DiffviewDiffAdd") or "NONE" - else - add_bg = M.get_bg("DiffviewDiffAdd") or "NONE" - end - local add_style = #add_style_attrs > 0 and table.concat(add_style_attrs, ",") or "NONE" - -- `explicit` for the same reason as `DiffviewDiffDeleteInline` above. - M.hi("DiffviewDiffAddInline", { - bg = add_bg, - style = add_style, - explicit = true, - }) + -- `diff1_inline` overlays for changed/added char ranges (priority 200, + -- layered on the paired row). The two inline styles need different + -- backdrops, so each derives from a different source group: + -- * "unified" paints the paired row with `DiffviewDiffChange` and + -- overlays `DiffviewDiffTextInline`, derived from `DiffText` -- the + -- same group the built-in side-by-side diff uses for intra-line + -- changes. Deriving from `DiffText` (not `DiffAdd`) keeps the overlay + -- visible against the `DiffChange` backdrop even when a colourscheme + -- gives `DiffAdd` and `DiffChange` near-identical backgrounds (e.g. + -- tokyonight), which would otherwise hide the change. Falls back to + -- `DiffAdd` for the rare colourscheme that leaves `DiffText` unset. + -- * "overleaf" leaves the row unpainted and overlays + -- `DiffviewDiffAddInline`, derived from `DiffAdd` -- the natural + -- "added" colour read against the normal background. + derive_inline_hl("DiffviewDiffText", "DiffviewDiffTextInline", "DiffviewDiffAdd") + derive_inline_hl("DiffviewDiffAdd", "DiffviewDiffAddInline") end function M.setup() diff --git a/lua/diffview/scene/inline_diff.lua b/lua/diffview/scene/inline_diff.lua index 7bd1bcf3..d15db265 100644 --- a/lua/diffview/scene/inline_diff.lua +++ b/lua/diffview/scene/inline_diff.lua @@ -553,6 +553,13 @@ end -- the span (the full line for word-level hunks, a single token for -- refined char-level sub-hunks) and serves as the deletion-anchor -- fallback when an index falls off the map. +-- +-- When `ranges` is supplied, every char-overlay byte range +-- (`{start_col, end_col}`) this call emits is appended to it. The caller +-- uses that list to lay the row backdrop with gaps so its priority-99 +-- `bg` doesn't sit under the priority-200 overlay (which would let the +-- backdrop's `bg` overpaint the overlay's on the changed cells — see +-- `lay_backdrop_with_gaps`). ---@param bufnr integer ---@param new_row integer ---@param base_byte integer @@ -562,6 +569,8 @@ end ---@param new_count integer ---@param del_text string Joined deleted units ("" if none). ---@param inline_del boolean +---@param add_hl string Highlight group for the inserted char range. +---@param ranges? integer[][] Out-parameter: each emitted overlay's `{start_col, end_col}` is appended. local function render_hunk( bufnr, new_row, @@ -571,7 +580,9 @@ local function render_hunk( new_start, new_count, del_text, - inline_del + inline_del, + add_hl, + ranges ) if new_count > 0 then -- A hunk is a contiguous range, so emit one extmark spanning all units @@ -579,11 +590,16 @@ local function render_hunk( local first = byte_map[new_start] local last = byte_map[new_start + new_count - 1] if first and last then - api.nvim_buf_set_extmark(bufnr, M.ns, new_row, base_byte + first.byte, { - end_col = base_byte + last.byte + last.byte_len, - hl_group = "DiffviewDiffAddInline", + local s = base_byte + first.byte + local e = base_byte + last.byte + last.byte_len + api.nvim_buf_set_extmark(bufnr, M.ns, new_row, s, { + end_col = e, + hl_group = add_hl, priority = 200, }) + if ranges then + ranges[#ranges + 1] = { s, e } + end else -- Defensive fallback when byte_map can't resolve both ends — should -- not happen with a well-formed UTF-8 string but handle it so partial @@ -591,11 +607,16 @@ local function render_hunk( for k = new_start, new_start + new_count - 1 do local info = byte_map[k] if info then - api.nvim_buf_set_extmark(bufnr, M.ns, new_row, base_byte + info.byte, { - end_col = base_byte + info.byte + info.byte_len, - hl_group = "DiffviewDiffAddInline", + local s = base_byte + info.byte + local e = base_byte + info.byte + info.byte_len + api.nvim_buf_set_extmark(bufnr, M.ns, new_row, s, { + end_col = e, + hl_group = add_hl, priority = 200, }) + if ranges then + ranges[#ranges + 1] = { s, e } + end end end end @@ -639,26 +660,29 @@ end ---@param old_line string ---@param new_line string ---@param inline_del boolean Render deleted units as inline virt_text. ----@return "ok"|"noop"|"skipped" # `ok`: rendered; `noop`: identical (nothing to do); `skipped`: fragmented, caller may want to fall back. -local function render_char_highlights(bufnr, new_row, old_line, new_line, inline_del) +---@param add_hl string Highlight group for changed/added char ranges. +---@return "ok"|"noop"|"skipped" result `ok`: rendered; `noop`: identical (nothing to do); `skipped`: fragmented, caller may want to fall back. +---@return integer[][] ranges Each `{start_col, end_col}` covers a char-overlay extmark this call emitted. Empty unless `result == "ok"`; the caller uses it to lay the row backdrop with gaps so the priority-200 overlay's `bg` is not overpainted by the priority-99 backdrop. Ranges may overlap when a paired line refines through `render_hunk`'s defensive byte-map fallback path; the merging in `lay_backdrop_with_gaps` collapses them. +local function render_char_highlights(bufnr, new_row, old_line, new_line, inline_del, add_hl) + local ranges = {} if old_line == new_line then - return "noop" + return "noop", ranges end -- Blank-to-nonblank (or vice versa) has no meaningful char-level diff, but -- the lines differ: signal `skipped` so the caller's fallback path still -- renders a line highlight / echoes the old line in overleaf style. if old_line == "" or new_line == "" then - return "skipped" + return "skipped", ranges end local old_tokens = tokenize(old_line) local new_tokens, new_map = tokenize(new_line) local hunks = diff_units(old_tokens, new_tokens) if #hunks == 0 then - return "noop" + return "noop", ranges end if #hunks > INTRALINE_MAX_HUNKS then - return "skipped" + return "skipped", ranges end local new_line_len = #new_line @@ -722,7 +746,9 @@ local function render_char_highlights(bufnr, new_row, old_line, new_line, inline sns, snc, del_text, - inline_del + inline_del, + add_hl, + ranges ) end end @@ -747,12 +773,88 @@ local function render_char_highlights(bufnr, new_row, old_line, new_line, inline new_start, new_count, del_text, - inline_del + inline_del, + add_hl, + ranges ) end end - return "ok" + return "ok", ranges +end + +-- Paint a row-wide backdrop in segments around the char-overlay ranges +-- `ranges` so the priority-99 backdrop's `bg` is never laid under a +-- priority-200 char overlay. Without this split, Neovim's compositor +-- prefers the backdrop's `bg` over the higher-priority overlay's `bg` +-- on cells where they coexist (a quirk that also affected +-- `line_hl_group`; switching to `hl_group + hl_eol` was an earlier +-- attempt at the workaround but only mitigated `fg`, not `bg`). Each +-- gap is an ordinary `hl_group` extmark; the final segment additionally +-- carries `hl_eol = true` so the row continues to fill past EOL the way +-- a single full-row backdrop would. +-- +-- The N+1 backdrop extmarks per row (where N = #ranges) are individually +-- shorter than the single full-row extmark they replace, so total work +-- is similar. Ranges may overlap or be unsorted on input (the defensive +-- per-byte fallback in `render_hunk` can emit them); they are sorted and +-- merged before emitting. +-- TODO: revisit when neovim/neovim#31151 lands — at that point a single +-- `line_hl_group` at the right priority should suffice without the +-- gap-splitting. +---@param bufnr integer +---@param row integer 0-indexed. +---@param line_hl string Backdrop highlight group. +---@param ranges integer[][] Char-overlay byte ranges (`{start_col, end_col}`). +local function lay_backdrop_with_gaps(bufnr, row, line_hl, ranges) + -- Sort + merge a defensive copy of the input so overlapping/touching + -- ranges (possible via `render_hunk`'s defensive per-byte fallback) + -- don't produce zero-length gap segments. With an empty input, `merged` + -- stays empty and the trailing extmark below paints the full row. + local merged = {} + if #ranges > 0 then + local sorted = {} + for _, r in ipairs(ranges) do + sorted[#sorted + 1] = { r[1], r[2] } + end + table.sort(sorted, function(a, b) + return a[1] < b[1] + end) + + merged[1] = sorted[1] + for i = 2, #sorted do + local last = merged[#merged] + local cur = sorted[i] + if cur[1] <= last[2] then + if cur[2] > last[2] then + last[2] = cur[2] + end + else + merged[#merged + 1] = cur + end + end + end + + local cursor = 0 + for _, r in ipairs(merged) do + if r[1] > cursor then + api.nvim_buf_set_extmark(bufnr, M.ns, row, cursor, { + end_col = r[1], + hl_group = line_hl, + priority = 99, + }) + end + cursor = r[2] + end + -- Trailing segment, including the `hl_eol` fill past the last byte + -- (also covers the no-ranges case as a single full-row backdrop). + api.nvim_buf_set_extmark(bufnr, M.ns, row, cursor, { + end_row = row + 1, + end_col = 0, + hl_group = line_hl, + hl_eol = true, + priority = 99, + }) end -- A single tree-sitter capture span on one line: `{col_start, col_end, @@ -1585,6 +1687,7 @@ end ---@field inline_del boolean Render paired char-level deletions as inline virt_text. ---@field echo_paired_old boolean Emit full old content as virt_lines above paired modifications. ---@field change_line_hl? string Line highlight on paired modified rows, or `nil` to skip. +---@field add_inline_hl string Highlight group for changed/added char ranges on paired rows. ---@type table local STYLES = { @@ -1594,6 +1697,10 @@ local STYLES = { inline_del = false, echo_paired_old = true, change_line_hl = "DiffviewDiffChange", + -- Changed chars sit on the `DiffviewDiffChange` paired row, so use the + -- `DiffText`-derived overlay (as the built-in side-by-side diff does) to + -- stay legible against that backdrop. + add_inline_hl = "DiffviewDiffTextInline", }, -- Overleaf style: deletions rendered inline as strikethrough virt_text so -- the reader sees the change in flow. No block echo, no line hl — the @@ -1603,6 +1710,9 @@ local STYLES = { inline_del = true, echo_paired_old = false, change_line_hl = nil, + -- No line backdrop; inserted chars read as additions over the normal + -- background, so use the `DiffAdd`-derived overlay. + add_inline_hl = "DiffviewDiffAddInline", }, } @@ -1793,22 +1903,30 @@ function M.render(bufnr, old_lines, new_lines, opts) local row = new_start - 1 + k local ol = old_lines[old_start + k] or "" local nl = new_lines[new_start + k] or "" - local char_result = render_char_highlights(bufnr, row, ol, nl, style.inline_del) + local char_result, char_ranges = + render_char_highlights(bufnr, row, ol, nl, style.inline_del, style.add_inline_hl) - -- `"skipped"` draws no `DiffviewDiffAddInline` overlay, so the - -- subtle `DiffviewDiffChange` backdrop alone would be a bare - -- smudge. Treat as a pure addition — the deletion is still - -- echoed above (unified unconditionally; overleaf via the - -- fallback below). + -- `"skipped"` draws no char-level overlay, so the subtle + -- `DiffviewDiffChange` backdrop alone would be a bare smudge. Treat + -- as a pure addition — the deletion is still echoed above (unified + -- unconditionally; overleaf via the fallback below). local line_hl = style.change_line_hl if char_result == "skipped" then line_hl = "DiffviewDiffAdd" + char_ranges = {} end if line_hl then - api.nvim_buf_set_extmark(bufnr, M.ns, row, 0, { - line_hl_group = line_hl, - priority = 100, - }) + -- Paint the row backdrop in gaps around the char overlays so the + -- priority-99 backdrop's `bg` is never laid under the priority-200 + -- overlay (otherwise the compositor lets the lower-priority `bg` + -- overpaint the overlay's on the changed cells -- a quirk that + -- previously hid bg overrides on `DiffviewDiffTextInline` under + -- colourschemes whose `DiffviewDiffChange` and `DiffText` differ + -- in bg, e.g. tokyonight). Priority 99 keeps the backdrop one + -- below tree-sitter's default 100, so syntax `fg` still wins + -- ties on the row (the priority-200 char overlay still wins on + -- changed cells). + lay_backdrop_with_gaps(bufnr, row, line_hl, char_ranges) end -- Overleaf fallback: when char-level was skipped and we're not diff --git a/lua/diffview/tests/functional/inline_diff_spec.lua b/lua/diffview/tests/functional/inline_diff_spec.lua index 5e25e16e..b51c462b 100644 --- a/lua/diffview/tests/functional/inline_diff_spec.lua +++ b/lua/diffview/tests/functional/inline_diff_spec.lua @@ -15,11 +15,20 @@ describe("diffview.scene.inline_diff", function() return api.nvim_buf_get_extmarks(bufnr, inline_diff.ns, 0, -1, { details = true }) end + -- Treat both `line_hl_group` (legacy) and `hl_group + hl_eol` (current + -- paired-row backdrop, switched in inline_diff.lua so the priority-200 char + -- overlay's `bg` can win) as a row-wide line highlight for assertion + -- purposes. local function line_hls(marks) local out = {} for _, m in ipairs(marks) do - if m[4] and m[4].line_hl_group then - out[#out + 1] = { row = m[2], hl = m[4].line_hl_group } + local d = m[4] + if d then + if d.line_hl_group then + out[#out + 1] = { row = m[2], hl = d.line_hl_group } + elseif d.hl_group and d.hl_eol then + out[#out + 1] = { row = m[2], hl = d.hl_group } + end end end table.sort(out, function(a, b) @@ -45,11 +54,14 @@ describe("diffview.scene.inline_diff", function() return out end - local function char_ranges(marks) + local function char_ranges(marks, hl) + hl = hl or "DiffviewDiffAddInline" local out = {} for _, m in ipairs(marks) do - if m[4] and m[4].hl_group == "DiffviewDiffAddInline" then - out[#out + 1] = { row = m[2], start = m[3], finish = m[4].end_col } + local d = m[4] + -- Exclude `hl_eol` marks: those are the row-wide backdrops, not char ranges. + if d and d.hl_group == hl and not d.hl_eol then + out[#out + 1] = { row = m[2], start = m[3], finish = d.end_col } end end table.sort(out, function(a, b) @@ -201,16 +213,19 @@ describe("diffview.scene.inline_diff", function() assert.are.same({}, virt_line_counts(extmarks(bufnr))) end) - it("marks modified lines with DiffChange and char-level DiffviewDiffAddInline", function() + it("marks modified lines with DiffChange and char-level DiffviewDiffTextInline", function() local bufnr = fresh_buf({ "hello wonderful world" }) inline_diff.render(bufnr, { "hello world" }, { "hello wonderful world" }) local hls = line_hls(extmarks(bufnr)) assert.are.same({ { row = 0, hl = "DiffviewDiffChange" } }, hls) - -- Expect at least one char-level DiffviewDiffAddInline range covering "wonderful ". - local ranges = char_ranges(extmarks(bufnr)) - assert.is_true(#ranges > 0, "expected DiffviewDiffAddInline extmarks") + -- Expect at least one char-level DiffviewDiffTextInline range covering + -- "wonderful ". The unified style overlays the `DiffText`-derived group + -- (not `DiffviewDiffAddInline`) so the change contrasts with the + -- `DiffviewDiffChange` backdrop, matching the side-by-side diff. + local ranges = char_ranges(extmarks(bufnr), "DiffviewDiffTextInline") + assert.is_true(#ranges > 0, "expected DiffviewDiffTextInline extmarks") -- Unified style echoes the old line above the modification. local vls = virt_line_counts(extmarks(bufnr)) @@ -415,6 +430,248 @@ describe("diffview.scene.inline_diff", function() end) end) + describe("DiffviewDiffTextInline highlight", function() + local hl = require("diffview.hl") + local saved + + -- Snapshot every `Diffview*` group plus the source diff groups this block + -- mutates so the changes don't leak into later tests (see the matching + -- note in the `DiffviewDiffAddInline highlight` block). + local function snapshot() + local groups = {} + for name in pairs(api.nvim_get_hl(0, {})) do + if name:match("^Diffview") then + groups[name] = api.nvim_get_hl(0, { name = name, link = true }) + end + end + for _, name in ipairs({ "DiffAdd", "DiffChange", "DiffText" }) do + groups[name] = api.nvim_get_hl(0, { name = name, link = true }) + end + return groups + end + + local function restore(groups) + for name in pairs(api.nvim_get_hl(0, {})) do + if name:match("^Diffview") then + api.nvim_set_hl(0, name, {}) + end + end + for name, h in pairs(groups) do + api.nvim_set_hl(0, name, h) + end + end + + -- Configure a "tokyonight-like" palette: `DiffAdd` and `DiffChange` share + -- a near-identical dark background while `DiffText` is distinct. This is + -- the exact shape that hid char-level changes when the unified overlay + -- derived from `DiffAdd` over the `DiffChange` paired row. + before_each(function() + saved = snapshot() + api.nvim_set_hl(0, "DiffAdd", { bg = "#2a4556" }) + api.nvim_set_hl(0, "DiffChange", { bg = "#252a3f" }) + api.nvim_set_hl(0, "DiffText", { bg = "#394b70" }) + -- Link the source `Diffview*` groups explicitly rather than relying on + -- `hl.setup()`'s default links: a `default` link no-ops when the group + -- already exists (as it does after the plugin's own setup), which would + -- leave these unlinked and the derived backgrounds nil. + api.nvim_set_hl(0, "DiffviewDiffAdd", { link = "DiffAdd" }) + api.nvim_set_hl(0, "DiffviewDiffChange", { link = "DiffChange" }) + api.nvim_set_hl(0, "DiffviewDiffText", { link = "DiffText" }) + api.nvim_set_hl(0, "DiffviewDiffAddInline", {}) + api.nvim_set_hl(0, "DiffviewDiffTextInline", {}) + hl.setup() + end) + + after_each(function() + restore(saved) + end) + + it("inherits bg from DiffviewDiffText and omits fg", function() + local got = api.nvim_get_hl(0, { name = "DiffviewDiffTextInline", link = false }) + local diff_text = api.nvim_get_hl(0, { name = "DiffviewDiffText", link = false }) + + -- fg must be absent so tree-sitter foreground composes through the + -- priority-200 extmark (otherwise it would stomp the syntax fg). + assert.is_nil(got.fg) + assert.are.equal(diff_text.bg, got.bg) + end) + + it("contrasts with the DiffChange backdrop when DiffAdd matches DiffChange", function() + -- Regression for issue #205: with a colourscheme whose `DiffAdd` and + -- `DiffChange` backgrounds are near-identical (tokyonight), deriving + -- the unified char overlay from `DiffAdd` made it blend into the + -- paired `DiffviewDiffChange` row. Deriving from `DiffText` restores + -- the contrast the built-in side-by-side diff has. + local text_inline = api.nvim_get_hl(0, { name = "DiffviewDiffTextInline", link = false }) + local change = api.nvim_get_hl(0, { name = "DiffviewDiffChange", link = false }) + + assert.is_not_nil(text_inline.bg) + assert.are_not.equal(change.bg, text_inline.bg) + assert.are.equal(0x394b70, text_inline.bg) + end) + + it("falls back to DiffAdd when the colourscheme defines no DiffText bg", function() + -- A scheme that defines `DiffAdd`/`DiffChange` but leaves `DiffText` + -- empty must not regress to an invisible overlay: fall back to the + -- `DiffAdd`-derived background (the pre-`DiffText` default) so the + -- change stays visible. + api.nvim_set_hl(0, "DiffText", {}) + api.nvim_set_hl(0, "DiffviewDiffText", { link = "DiffText" }) + hl.setup() + + local got = api.nvim_get_hl(0, { name = "DiffviewDiffTextInline", link = false }) + local diff_add = api.nvim_get_hl(0, { name = "DiffviewDiffAdd", link = false }) + assert.is_nil(got.fg) + assert.are.equal(diff_add.bg, got.bg) + end) + + it("refreshes on re-setup instead of pinning the first colourscheme's value", function() + -- Regression: the inline groups were defined with `default = true`, so + -- once set they never tracked later `ColorScheme` events (a `default` + -- highlight is a no-op when the group already exists). A colourscheme + -- switch -- modelled here as a new `DiffText` background plus a fresh + -- `hl.setup()`, WITHOUT clearing the group first -- must update the + -- derived background rather than keep the stale one. + assert.are.equal( + 0x394b70, + api.nvim_get_hl(0, { name = "DiffviewDiffTextInline", link = false }).bg + ) + + api.nvim_set_hl(0, "DiffText", { bg = "#88ccff" }) + api.nvim_set_hl(0, "DiffviewDiffText", { link = "DiffText" }) + hl.setup() + + assert.are.equal( + 0x88ccff, + api.nvim_get_hl(0, { name = "DiffviewDiffTextInline", link = false }).bg + ) + end) + + it("renders the unified char overlay with a bg distinct from the modified row", function() + -- End-to-end: a modified line in the unified style overlays + -- `DiffviewDiffTextInline` on the changed chars, and its resolved + -- background must differ from the `DiffviewDiffChange` paired row. + local bufnr = fresh_buf({ "hello wonderful world" }) + inline_diff.render(bufnr, { "hello world" }, { "hello wonderful world" }) + + local row_bg = api.nvim_get_hl(0, { name = "DiffviewDiffChange", link = false }).bg + local overlay_bg + for _, m in ipairs(extmarks(bufnr)) do + if m[4] and m[4].hl_group == "DiffviewDiffTextInline" then + overlay_bg = api.nvim_get_hl(0, { name = m[4].hl_group, link = false }).bg + end + end + + assert.is_not_nil(overlay_bg) + assert.are_not.equal(row_bg, overlay_bg) + end) + + -- Regression: a single `hl_group + hl_eol` backdrop spanning the whole + -- row let Neovim's compositor overpaint the priority-200 char overlay's + -- `bg` on the changed-char cells. The renderer now lays the backdrop + -- as N+1 segments around the char-overlay ranges so no priority-99 + -- `bg` sits underneath the overlay. + it("paints the row backdrop in gaps around the char overlay range", function() + local bufnr = fresh_buf({ "hello wonderful world" }) + inline_diff.render(bufnr, { "hello world" }, { "hello wonderful world" }) + + local marks = extmarks(bufnr) + local overlay + local backdrops = {} + for _, m in ipairs(marks) do + local d = m[4] or {} + if d.hl_group == "DiffviewDiffTextInline" and not d.hl_eol then + overlay = { row = m[2], start = m[3], finish = d.end_col } + elseif d.hl_group == "DiffviewDiffChange" then + backdrops[#backdrops + 1] = { + row = m[2], + start = m[3], + finish = d.end_col, + hl_eol = d.hl_eol or false, + end_row = d.end_row, + } + end + end + + assert.is_not_nil(overlay, "expected a DiffviewDiffTextInline overlay") + assert.is_true(#backdrops > 0, "expected DiffviewDiffChange backdrop segments") + + -- No backdrop segment may overlap the overlay range; that overlap is + -- the exact configuration that lets the compositor's row-bg quirk + -- overpaint the overlay. + for _, b in ipairs(backdrops) do + if b.row == overlay.row then + assert.is_true( + b.finish <= overlay.start or b.start >= overlay.finish, + string.format( + "backdrop [%d,%d) overlaps overlay [%d,%d) on row %d", + b.start, + b.finish, + overlay.start, + overlay.finish, + overlay.row + ) + ) + end + end + + -- Exactly one trailing segment carries `hl_eol = true` so the row's + -- background continues past the last char the same way the + -- pre-split single backdrop did. + local eol_count = 0 + for _, b in ipairs(backdrops) do + if b.row == overlay.row and b.hl_eol then + eol_count = eol_count + 1 + assert.is_true( + b.start >= overlay.finish, + "the `hl_eol` segment must start at or after the overlay end" + ) + end + end + assert.are.equal(1, eol_count) + end) + + -- Regression: a user override of `DiffviewDiffTextInline { bg = ... }` + -- has to survive on the changed-char cells. Before the gap-backdrop + -- fix the priority-99 row backdrop sat under the priority-200 overlay + -- and the compositor preferred the backdrop's `bg`, so the override + -- never showed. + it("preserves a user-set `bg` on DiffviewDiffTextInline at the overlay cells", function() + api.nvim_set_hl(0, "DiffviewDiffTextInline", { bg = "#ffffff" }) + + local bufnr = fresh_buf({ "hello wonderful world" }) + inline_diff.render(bufnr, { "hello world" }, { "hello wonderful world" }) + + -- The override is the highlight on the priority-200 extmark, and no + -- priority-99 backdrop overlaps it — so the compositor's view of the + -- overlay cells reads bg=white. + local marks = extmarks(bufnr) + local overlay + for _, m in ipairs(marks) do + local d = m[4] or {} + if d.hl_group == "DiffviewDiffTextInline" and not d.hl_eol then + overlay = { row = m[2], start = m[3], finish = d.end_col } + break + end + end + assert.is_not_nil(overlay) + assert.are.equal( + 0xffffff, + api.nvim_get_hl(0, { name = "DiffviewDiffTextInline", link = false }).bg + ) + + for _, m in ipairs(marks) do + local d = m[4] or {} + if d.hl_group == "DiffviewDiffChange" and m[2] == overlay.row then + assert.is_true( + d.end_col <= overlay.start or m[3] >= overlay.finish, + "backdrop must not overlap the overlay carrying the user bg" + ) + end + end + end) + end) + describe("overleaf style", function() it("emits inline virt_text for char-level deletions on paired lines", function() local bufnr = fresh_buf({ "hello world" }) @@ -1146,7 +1403,8 @@ describe("diffview.scene.inline_diff", function() local out = { hl = 0, inline = 0 } for _, m in ipairs(marks) do local d = m[4] - if d.hl_group == "DiffviewDiffAddInline" then + -- Unified style overlays the `DiffText`-derived group on changed chars. + if d.hl_group == "DiffviewDiffTextInline" then out.hl = out.hl + 1 end if d.virt_text and d.virt_text_pos == "inline" then @@ -1160,7 +1418,7 @@ describe("diffview.scene.inline_diff", function() local bufnr = fresh_buf({ "hello brave world" }) inline_diff.render(bufnr, { "hello world" }, { "hello brave world" }) local t = text_extmarks(bufnr) - assert.is_true(t.hl > 0, "expected DiffviewDiffAddInline on added chars for similar lines") + assert.is_true(t.hl > 0, "expected DiffviewDiffTextInline on added chars for similar lines") end) it("skips char-level highlights when lines are too dissimilar (unified)", function() @@ -1172,7 +1430,11 @@ describe("diffview.scene.inline_diff", function() local bufnr = fresh_buf({ new }) inline_diff.render(bufnr, { old }, { new }) local t = text_extmarks(bufnr) - assert.are.equal(0, t.hl, "expected no DiffviewDiffAddInline fragments on dissimilar pairing") + assert.are.equal( + 0, + t.hl, + "expected no DiffviewDiffTextInline fragments on dissimilar pairing" + ) end) it("skips inline deletions in overleaf for dissimilar pairings", function() @@ -1215,7 +1477,7 @@ describe("diffview.scene.inline_diff", function() local new_line = ' "x": { "commit": "cf4c30892644f01ebfb1e248eeca9e259856f9dc" }' local bufnr = fresh_buf({ new_line }) inline_diff.render(bufnr, { old_line }, { new_line }) - local cr = char_ranges(extmarks(bufnr)) + local cr = char_ranges(extmarks(bufnr), "DiffviewDiffTextInline") assert.are.equal(1, #cr) local hash_start = string.find(new_line, "cf4c3089", 1, true) - 1 assert.are.equal(0, cr[1].row) @@ -1256,13 +1518,13 @@ describe("diffview.scene.inline_diff", function() it("refines 1:1 word replacements with char-level precision", function() -- "recieve" → "receive" is one word-level 1:1 pair, so the sub-diff - -- kicks in. DiffviewDiffAddInline highlights should cover only the moved letter - -- rather than the whole word. + -- kicks in. Unified char highlights (DiffviewDiffTextInline) should cover + -- only the moved letter rather than the whole word. local bufnr = fresh_buf({ "receive" }) inline_diff.render(bufnr, { "recieve" }, { "receive" }) - local ranges = char_ranges(extmarks(bufnr)) - assert.is_true(#ranges > 0, "expected char-level DiffviewDiffAddInline inside the word") + local ranges = char_ranges(extmarks(bufnr), "DiffviewDiffTextInline") + assert.is_true(#ranges > 0, "expected char-level DiffviewDiffTextInline inside the word") for _, r in ipairs(ranges) do assert.is_true( r.finish - r.start < #"receive",