From 4dc37d7abf1233c9dcf33a31a634321ba733e82d Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Wed, 18 Mar 2026 20:42:32 +0100 Subject: [PATCH 01/14] fix(renderer): use visual width for table wrap check The table wrap check used vim_width (raw text width including concealed content like URLs) to decide whether to skip rendering. This caused tables with long hyperlinks to not render at all when wrap is enabled, even though the visual width after conceal was well within the window. Use col_widths (visual width after conceal resolution) instead, which correctly reflects the rendered table width. --- lua/markview/renderers/markdown.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/markview/renderers/markdown.lua b/lua/markview/renderers/markdown.lua index 30b6152..80b4162 100644 --- a/lua/markview/renderers/markdown.lua +++ b/lua/markview/renderers/markdown.lua @@ -1652,7 +1652,7 @@ markdown.table = function (buffer, item) local table_width = 1; - for _, col in ipairs(vim_width) do + for _, col in ipairs(col_widths) do table_width = table_width + 1 + col; end From ce3446dc2b2904667dfbb8801c6632fc579ae7e5 Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Wed, 18 Mar 2026 21:23:11 +0100 Subject: [PATCH 02/14] fix(tostring): handle strikethrough markers in visual width calculation Add strikethrough handler to strip ~~ markers when computing visual text width. Without this, ~~text~~ in table cells inflated column widths by 4 extra characters (2 per marker pair). - Add md_str.strikethrough() to strip ~~ delimiters - Add LPEG strike pattern for ~~content~~ syntax - Register strike in the token alternatives --- lua/markview/renderers/markdown/tostring.lua | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lua/markview/renderers/markdown/tostring.lua b/lua/markview/renderers/markdown/tostring.lua index f573ac5..6a73c82 100644 --- a/lua/markview/renderers/markdown/tostring.lua +++ b/lua/markview/renderers/markdown/tostring.lua @@ -364,6 +364,21 @@ md_str.escape = function (match) return char; end +---@param match string +---@return string +md_str.strikethrough = function (match) + ---|fS + + if string.match(match, "%s+%~%~$") then + return match; + end + + local removed = string.gsub(match, "^%~%~", ""):gsub("%~%~$", ""); + return removed; + + ---|fE +end + ---@param match string ---@return string md_str.italic = function (match) @@ -727,11 +742,14 @@ local emoji = lpeg.C( lpeg.P(":") * emoji_char^1 * lpeg.P(":") ) / md_str.emoji; local hl_content = lpeg.P("\\=") + ( 1 - lpeg.P("=") ); local hl = lpeg.C( lpeg.P("==") * hl_content^1 * lpeg.P("==") ) / md_str.highlight; +local strike_content = lpeg.P("\\~") + ( 1 - lpeg.P("~") ); +local strike = lpeg.C( lpeg.P("~~") * strike_content^1 * lpeg.P("~~") ) / md_str.strikethrough; + local any = lpeg.P(1); local token = escape + emoji + entity + - hl + block_ref + embed + internal + + hl + strike + block_ref + embed + internal + email + auto + footnote + img + hyperlink + code + From ad33bd34a46f7a4c65675dc59e650d98b85c67e1 Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Wed, 18 Mar 2026 23:48:29 +0100 Subject: [PATCH 03/14] fix(table): add wrap continuation borders via deferred post_render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a table row contains long concealed text (e.g. URLs), the raw buffer line wraps even though the visual content fits. This places table border characters (│...│) on wrap continuation screen rows to preserve the visual table structure. The placement is deferred to post_render (markdown.__table) which runs after all renderers including markdown_inline. This is critical because inline extmarks (padding, conceal) affect the visual line height — if checked too early, nvim_win_text_height reports no wrapping for lines that will wrap once inline extmarks are added. Key design decisions: - Use nvim_win_text_height for accurate wrap detection (accounts for inline extmarks and linebreak, unlike strdisplaywidth which is context-dependent with linebreak=true) - Use binary search with screenpos for precise wrap boundary positions (respects linebreak word-boundary wrapping) - Store continuation_vt on the item during markdown.table, register in markdown.cache for post_render dispatch --- lua/markview/renderers/markdown.lua | 99 +++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/lua/markview/renderers/markdown.lua b/lua/markview/renderers/markdown.lua index 80b4162..f4c29c8 100644 --- a/lua/markview/renderers/markdown.lua +++ b/lua/markview/renderers/markdown.lua @@ -2493,6 +2493,105 @@ markdown.table = function (buffer, item) c = c + 1; end end + + --- Store data needed for wrap continuation borders. + --- The actual placement is deferred to `markdown.__table` (post_render), + --- which runs after all renderers (including markdown_inline) have + --- placed their extmarks. This ensures `nvim_win_text_height` reflects + --- the true visual line height including inline conceal/padding. + if is_wrapped == true then + --- Build continuation line virtual text from col_widths. + --- Pattern: ││ + local continuation_vt = {}; + local left_border, left_hl = get_border("row", 1); + + table.insert(continuation_vt, { left_border, left_hl }); + + for col_c = 1, #col_widths do + table.insert(continuation_vt, { string.rep(" ", col_widths[col_c]) }); + + if col_c < #col_widths then + local mid_border, mid_hl = get_border("row", 2); + table.insert(continuation_vt, { mid_border, mid_hl }); + else + local right_border, right_hl = get_border("row", 3); + table.insert(continuation_vt, { right_border, right_hl }); + end + end + + item.__continuation_vt = continuation_vt; + + --- Register for post_render so __table runs after inline extmarks. + table.insert(markdown.cache, item); + end +end + + + ----------------------------------------------------------------------------------------- + + +--- Places table border characters on wrap continuation lines (post_render). +--- +--- Runs after all renderers (including markdown_inline) have placed their +--- extmarks, so `nvim_win_text_height` accurately reflects the visual line +--- height. Uses binary search with `screenpos` for precise wrap boundary +--- positions. +---@param buffer integer +---@param item markview.parsed.markdown.tables +markdown.__table = function (buffer, item) + local continuation_vt = item.__continuation_vt; + + if not continuation_vt then + return; + end + + local win = utils.buf_getwin(buffer); + + if not win then + return; + end + + local range = item.range; + + vim.api.nvim_win_call(win, function() + for row = range.row_start, range.row_end - 1 do + local height = vim.api.nvim_win_text_height(win, { + start_row = row, end_row = row + }); + + if height.all > 1 then + local line_len = #(vim.api.nvim_buf_get_lines(buffer, row, row + 1, false)[1] or ""); + local lnum = row + 1; + local first_screen_row = vim.fn.screenpos(win, lnum, 1).row; + + for w = 1, height.all - 1 do + local target_row = first_screen_row + w; + + --- Binary search for the first byte on `target_row`. + local lo, hi = 1, line_len; + + while lo < hi do + local mid = math.floor((lo + hi) / 2); + + if vim.fn.screenpos(win, lnum, mid).row < target_row then + lo = mid + 1; + else + hi = mid; + end + end + + if lo <= line_len then + vim.api.nvim_buf_set_extmark(buffer, markdown.ns, row, lo - 1, { + undo_restore = false, invalidate = true, + virt_text = continuation_vt, + virt_text_win_col = 0, + hl_mode = "combine", + }); + end + end + end + end + end); end From c02553068453e1e0c6d41853fb85510fb9ad633b Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Thu, 19 Mar 2026 00:01:13 +0100 Subject: [PATCH 04/14] fix(table): use markview highlights for all table borders in wrapped mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove is_wrapped guards that skipped pipe conceal/replacement and overrode highlights with @punctuation.special.markdown. Table borders now consistently use markview's own highlight groups (MarkviewTableBorder, MarkviewTableHeader) and fancy border characters (│) in both wrapped and non-wrapped modes. Concealing | and replacing with │ (same display width) does not affect Neovim's line wrapping behavior, so the guards were unnecessary. --- lua/markview/renderers/markdown.lua | 126 +++++++++++----------------- 1 file changed, 47 insertions(+), 79 deletions(-) diff --git a/lua/markview/renderers/markdown.lua b/lua/markview/renderers/markdown.lua index f4c29c8..3841338 100644 --- a/lua/markview/renderers/markdown.lua +++ b/lua/markview/renderers/markdown.lua @@ -1711,23 +1711,21 @@ markdown.table = function (buffer, item) table.insert(tmp, { top, - is_wrapped and "@punctuation.special.markdown" or utils.set_hl(top_hl) + utils.set_hl(top_hl) }); - if is_wrapped == false then - vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_start, range.col_start + part.col_start, { - undo_restore = false, invalidate = true, - end_col = range.col_start + part.col_end, - conceal = "", + vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_start, range.col_start + part.col_start, { + undo_restore = false, invalidate = true, + end_col = range.col_start + part.col_end, + conceal = "", - virt_text_pos = "inline", - virt_text = { - { border, border_hl } - }, + virt_text_pos = "inline", + virt_text = { + { border, border_hl } + }, - hl_mode = "combine" - }) - end + hl_mode = "combine" + }) if p == #item.header and config.block_decorator == true then @@ -1763,7 +1761,7 @@ markdown.table = function (buffer, item) table.insert(tmp, { top, - is_wrapped and "@punctuation.special.markdown" or utils.set_hl(top_hl) + utils.set_hl(top_hl) }); vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_start, range.col_start + part.col_start, { @@ -1775,7 +1773,7 @@ markdown.table = function (buffer, item) virt_text = { { border, - is_wrapped and "@punctuation.special.markdown" or utils.set_hl(border_hl) + utils.set_hl(border_hl) } }, @@ -1847,7 +1845,7 @@ markdown.table = function (buffer, item) table.insert(tmp, { string.rep(top, column_width), - is_wrapped and "@punctuation.special.markdown" or utils.set_hl(top_hl) + utils.set_hl(top_hl) }); if visible_width < column_width then @@ -1904,10 +1902,6 @@ markdown.table = function (buffer, item) local y = range.col_start + sep.col_start; if sep.class == "separator" then - if is_wrapped == true then - goto continue; - end - local border, border_hl = get_border("separator", 4); if s == 1 then @@ -1935,7 +1929,7 @@ markdown.table = function (buffer, item) undo_restore = false, invalidate = true, virt_text_pos = "inline", virt_text = { - is_wrapped == true and { "|", "@punctuation.special.markdown" } or { border, border_hl } + { border, border_hl } }, right_gravity = s ~= 1, @@ -1948,23 +1942,7 @@ markdown.table = function (buffer, item) local width = vim.fn.strdisplaywidth(sep.text); local left = col_widths[c] - width; - if is_wrapped == true then - if left > 0 then - vim.api.nvim_buf_set_extmark(buffer, markdown.ns, x, (range.col_start + sep.col_end) - 1, { - undo_restore = false, invalidate = true, - - virt_text_pos = "inline", - virt_text = { - { - string.rep("-", left), - "@punctuation.special.markdown" - } - }, - - hl_mode = "combine" - }); - end - elseif item.alignments[c] == "default" then + if item.alignments[c] == "default" then if left > 0 then vim.api.nvim_buf_set_extmark(buffer, markdown.ns, x, y, { undo_restore = false, invalidate = true, @@ -2178,37 +2156,32 @@ markdown.table = function (buffer, item) border, border_hl = get_border("row", 3); end - if is_wrapped == false then - vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_start + 1 + r, range.col_start + part.col_start, { - undo_restore = false, invalidate = true, - end_col = range.col_start + part.col_end, - conceal = "", + vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_start + 1 + r, range.col_start + part.col_start, { + undo_restore = false, invalidate = true, + end_col = range.col_start + part.col_end, + conceal = "", - virt_text_pos = "inline", - virt_text = { - { border, border_hl } - }, + virt_text_pos = "inline", + virt_text = { + { border, border_hl } + }, - hl_mode = "combine" - }) - end + hl_mode = "combine" + }) elseif part.class == "missing_seperator" then local border, border_hl = get_border("row", r == 1 and 1 or 3); vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_start + 1 + r, range.col_start + part.col_start, { undo_restore = false, invalidate = true, virt_text_pos = "inline", - virt_text = { - is_wrapped and { - "|", - "@punctuation.special.markdown" - } or { - border, - utils.set_hl(border_hl) - } - }, + virt_text = { + { + border, + utils.set_hl(border_hl) + } + }, - right_gravity = r ~= 1, + right_gravity = r ~= 1, hl_mode = "combine" }) elseif part.class == "column" then @@ -2310,23 +2283,21 @@ markdown.table = function (buffer, item) table.insert(tmp, { bottom, - is_wrapped and "@punctuation.special.markdown" or utils.set_hl(bottom_hl) + utils.set_hl(bottom_hl) }); - if is_wrapped == false then - vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_end - 1, range.col_start + part.col_start, { - undo_restore = false, invalidate = true, - end_col = range.col_start + part.col_end, - conceal = "", + vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_end - 1, range.col_start + part.col_start, { + undo_restore = false, invalidate = true, + end_col = range.col_start + part.col_end, + conceal = "", - virt_text_pos = "inline", - virt_text = { - { border, border_hl } - }, + virt_text_pos = "inline", + virt_text = { + { border, border_hl } + }, - hl_mode = "combine" - }); - end + hl_mode = "combine" + }); if p == #item.header and config.block_decorator == true then local next_line = range.row_end == vim.api.nvim_buf_line_count(buffer) and 0 or #vim.api.nvim_buf_get_lines(buffer, range.row_end, range.row_end + 1, false)[1]; @@ -2359,7 +2330,7 @@ markdown.table = function (buffer, item) table.insert(tmp, { bottom, - is_wrapped == true and "@punctuation.special.markdown" or utils.set_hl(bottom_hl) + utils.set_hl(bottom_hl) }); vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_end - 1, range.col_start + part.col_start, { @@ -2369,10 +2340,7 @@ markdown.table = function (buffer, item) virt_text_pos = "inline", virt_text = { - is_wrapped and { - "|", - "@punctuation.special.markdown" - } or { + { border, utils.set_hl(border_hl) } @@ -2440,7 +2408,7 @@ markdown.table = function (buffer, item) table.insert(tmp, { string.rep(bottom, column_width), - is_wrapped and "@punctuation.special.markdown" or utils.set_hl(bottom_hl) + utils.set_hl(bottom_hl) }); if visible_width < column_width then From ebf5fb1b25e38eaf97f64412cdfebb5ac0ebff17 Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Thu, 19 Mar 2026 00:10:56 +0100 Subject: [PATCH 05/14] fix(table): add right border on first screen row of wrapping lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a table row wraps, the concealed right pipe (│) ends up on the continuation screen line, leaving the first screen row without a right border. Fix by placing an additional │ via virt_text_win_col at the table's right edge (table_width - 1) on the first screen row. Stores right border virt_text and table visual width on the item during markdown.table, then places the overlay in markdown.__table post_render. --- lua/markview/renderers/markdown.lua | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lua/markview/renderers/markdown.lua b/lua/markview/renderers/markdown.lua index 3841338..0910830 100644 --- a/lua/markview/renderers/markdown.lua +++ b/lua/markview/renderers/markdown.lua @@ -2489,6 +2489,13 @@ markdown.table = function (buffer, item) item.__continuation_vt = continuation_vt; + --- Right border info for the first screen row of wrapping lines. + --- The concealed right pipe ends up on the continuation line, so + --- the first screen row needs an explicit right border overlay. + local right_border, right_hl = get_border("row", 3); + item.__right_border_vt = { { right_border, right_hl } }; + item.__table_width = utils.virt_len(continuation_vt); + --- Register for post_render so __table runs after inline extmarks. table.insert(markdown.cache, item); end @@ -2532,6 +2539,18 @@ markdown.__table = function (buffer, item) local lnum = row + 1; local first_screen_row = vim.fn.screenpos(win, lnum, 1).row; + --- Place right border on first screen row. + --- The concealed right pipe wraps to a continuation line, + --- leaving the first screen row without a right border. + if item.__right_border_vt and item.__table_width then + vim.api.nvim_buf_set_extmark(buffer, markdown.ns, row, 0, { + undo_restore = false, invalidate = true, + virt_text = item.__right_border_vt, + virt_text_win_col = item.__table_width - 1, + hl_mode = "combine", + }); + end + for w = 1, height.all - 1 do local target_row = first_screen_row + w; From a0b9bd5df3f64f6da7a990912131c6eca653d5b7 Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Thu, 19 Mar 2026 00:36:28 +0100 Subject: [PATCH 06/14] fix(table): correct top/bottom border indentation in nested tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a table appears inside an indented block (e.g. list items), the org_indent post-render adds visual indentation to lines. The top/bottom border extmarks included their own col_start-based indent, which was cumulative with org_indent — causing the top border to be indented too far and the bottom border not enough. Fix by computing the target visual indent from the first data row's org_indent marks in __table post_render, then adjusting each border's leading spaces: border_leading = target - border_line_org_indent. Also saves top/bottom border extmark IDs on the item so __table can find and update them. Both the separator and missing_separator code paths now store the bottom border ID. --- lua/markview/renderers/markdown.lua | 121 ++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 8 deletions(-) diff --git a/lua/markview/renderers/markdown.lua b/lua/markview/renderers/markdown.lua index 0910830..435a627 100644 --- a/lua/markview/renderers/markdown.lua +++ b/lua/markview/renderers/markdown.lua @@ -1746,7 +1746,7 @@ markdown.table = function (buffer, item) hl_mode = "combine" }) elseif item.top_border == true and range.row_start > 0 then - vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_start - 1, math.min(range.col_start, prev_line), { + item.__top_border_id = vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_start - 1, math.min(range.col_start, prev_line), { undo_restore = false, invalidate = true, virt_text_pos = "inline", virt_text = tmp, @@ -2316,7 +2316,7 @@ markdown.table = function (buffer, item) hl_mode = "combine" }) elseif range.row_end <= vim.api.nvim_buf_line_count(buffer) and item.bottom_border == true then - vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_end, math.min(next_line, range.col_start), { + item.__bottom_border_id = vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_end, math.min(next_line, range.col_start), { virt_text_pos = "inline", virt_text = tmp, @@ -2367,7 +2367,7 @@ markdown.table = function (buffer, item) hl_mode = "combine" }) elseif range.row_end <= vim.api.nvim_buf_line_count(buffer) and item.bottom_border == true then - vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_end, math.min(next_line, range.col_start), { + item.__bottom_border_id = vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_end, math.min(next_line, range.col_start), { virt_text_pos = "inline", virt_text = tmp, @@ -2499,21 +2499,126 @@ markdown.table = function (buffer, item) --- Register for post_render so __table runs after inline extmarks. table.insert(markdown.cache, item); end + + --- Register for post_render to fix border indentation when org_indent + --- adds extra spacing on the border lines (e.g. tables inside list items). + if item.__top_border_id or item.__bottom_border_id then + if not item.__continuation_vt then + table.insert(markdown.cache, item); + end + end end ----------------------------------------------------------------------------------------- ---- Places table border characters on wrap continuation lines (post_render). +--- Post-render handler for tables. --- ---- Runs after all renderers (including markdown_inline) have placed their ---- extmarks, so `nvim_win_text_height` accurately reflects the visual line ---- height. Uses binary search with `screenpos` for precise wrap boundary ---- positions. +--- 1. Fixes top/bottom border indentation when org_indent adds extra +--- spacing on the border lines (e.g. tables inside list items). +--- 2. Places wrap continuation borders and right-border overlays +--- (must run after markdown_inline so `nvim_win_text_height` is accurate). ---@param buffer integer ---@param item markview.parsed.markdown.tables markdown.__table = function (buffer, item) + local range = item.range; + + --- Fix border indentation by accounting for org_indent marks. + --- org_indent may add spacing on the border lines that conflicts + --- with the table's own col_start-based indentation. + --- + --- Strategy: compute the target visual indent from a data row, then + --- adjust each border's leading spaces based on how much org_indent + --- already contributes on the border's line. + if item.__top_border_id or item.__bottom_border_id then + --- Compute target indent from the first data row's org_indent. + local data_org_visual = 0; + local data_conceal_end = 0; + local data_marks = vim.api.nvim_buf_get_extmarks(buffer, markdown.ns, + { range.row_start, 0 }, { range.row_start, range.col_start }, { details = true }); + + for _, m in ipairs(data_marks) do + local d = m[4]; + + if d.conceal == "" and d.virt_text then + local vt_text = ""; + + for _, c in ipairs(d.virt_text) do + vt_text = vt_text .. (c[1] or ""); + end + + if vt_text:match("^%s+$") then + data_org_visual = data_org_visual + vim.fn.strdisplaywidth(vt_text); + data_conceal_end = math.max(data_conceal_end, d.end_col or 0); + end + end + end + + --- Target = org_indent visual width + remaining raw indent. + local target_indent = data_org_visual > 0 + and (data_org_visual + math.max(0, range.col_start - data_conceal_end)) + or nil; + + if target_indent then + for _, key in ipairs({ "__top_border_id", "__bottom_border_id" }) do + local mark_id = item[key]; + + if not mark_id then + goto next_border; + end + + local mark = vim.api.nvim_buf_get_extmark_by_id(buffer, markdown.ns, mark_id, { details = true }); + + if not mark or not mark[3] or not mark[3].virt_text then + goto next_border; + end + + local mark_row = mark[1]; + + --- Find org_indent marks on the border line. + local border_org_visual = 0; + local border_marks = vim.api.nvim_buf_get_extmarks(buffer, markdown.ns, + { mark_row, 0 }, { mark_row, range.col_start }, { details = true }); + + for _, m in ipairs(border_marks) do + local d = m[4]; + + if m[1] ~= mark_id and d.conceal == "" and d.virt_text then + local vt_text = ""; + + for _, c in ipairs(d.virt_text) do + vt_text = vt_text .. (c[1] or ""); + end + + if vt_text:match("^%s+$") then + border_org_visual = border_org_visual + vim.fn.strdisplaywidth(vt_text); + end + end + end + + --- Border leading spaces = target - what org_indent already provides. + local leading = math.max(0, target_indent - border_org_visual); + local vt = mark[3].virt_text; + + if vt[1] and type(vt[1][1]) == "string" and vt[1][1]:match("^%s*$") then + vt[1][1] = string.rep(" ", leading); + end + + vim.api.nvim_buf_set_extmark(buffer, markdown.ns, mark_row, mark[2], { + id = mark_id, + undo_restore = false, invalidate = true, + virt_text_pos = "inline", + virt_text = vt, + hl_mode = "combine", + }); + + ::next_border:: + end + end + end + + --- Wrap continuation borders. local continuation_vt = item.__continuation_vt; if not continuation_vt then From a407549417e3360d469e53a6aba5845458f2f26d Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Thu, 19 Mar 2026 09:36:26 +0100 Subject: [PATCH 07/14] fix: stabilize inline virt_text ordering at shared positions in table renderer Neovim's mark tree traversal order is not stable for inline virt_text when range marks (conceal+border) and point marks (padding/decoration) coexist at the same (row, col). The traversal order depends on the internal tree structure, which varies with the total number of marks in the buffer. In hybrid mode, filtering out preceding content changes how many marks are created, shifting the mark tree structure and causing padding/decoration marks to swap visual order with junction/border marks at shared positions. This made table borders appear misplaced when the cursor was on a heading or inside a nearby table. Fix: set right_gravity=false on all inline padding/decoration marks at col_end positions (header row and separator row). Neovim sorts right_gravity=false before right_gravity=true at the same position, guaranteeing stable traversal order regardless of tree structure. --- lua/markview/renderers/markdown.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lua/markview/renderers/markdown.lua b/lua/markview/renderers/markdown.lua index 435a627..3ae875b 100644 --- a/lua/markview/renderers/markdown.lua +++ b/lua/markview/renderers/markdown.lua @@ -1857,6 +1857,7 @@ markdown.table = function (buffer, item) { string.rep(" ", math.max(0, column_width - visible_width)) } }, + right_gravity = false, hl_mode = "combine" }); elseif item.alignments[c] == "right" then @@ -1867,6 +1868,7 @@ markdown.table = function (buffer, item) { string.rep(" ", math.max(0, column_width - visible_width)) } }, + right_gravity = false, hl_mode = "combine" }); else @@ -1877,6 +1879,7 @@ markdown.table = function (buffer, item) { string.rep(" ", math.ceil((column_width - visible_width) / 2)) } }, + right_gravity = false, hl_mode = "combine" }); vim.api.nvim_buf_set_extmark(buffer, markdown.ns, range.row_start, range.col_start + part.col_end, { @@ -1886,6 +1889,7 @@ markdown.table = function (buffer, item) { string.rep(" ", math.floor((column_width - visible_width) / 2)) } }, + right_gravity = false, hl_mode = "combine" }); end @@ -1967,6 +1971,7 @@ markdown.table = function (buffer, item) { string.rep(border, left), utils.set_hl(border_hl) }, }, + right_gravity = false, hl_mode = "combine" }); else @@ -2014,6 +2019,7 @@ markdown.table = function (buffer, item) { string.rep(border, left), utils.set_hl(border_hl) }, }, + right_gravity = false, hl_mode = "combine" }); else @@ -2062,6 +2068,7 @@ markdown.table = function (buffer, item) { align, utils.set_hl(align_hl) } }, + right_gravity = false, hl_mode = "combine" }); else @@ -2111,6 +2118,7 @@ markdown.table = function (buffer, item) { align[2], utils.set_hl(align_hl[2]) } }, + right_gravity = false, hl_mode = "combine" }); else From 7132298be571a36cdc5436f3b49dee41cff5769b Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Thu, 19 Mar 2026 09:52:26 +0100 Subject: [PATCH 08/14] fix: strip blockquote continuation prefix from table rows in parser get_node_text() only applies col_start offset to the first line of a tree-sitter node. For tables inside blockquotes, lines 2+ retain the '> ' prefix, causing the lpeg row parser to fail silently and produce empty results. This left separator and data rows unrendered. Strip the prefix via line:sub(col_start + 1) for lines after the first, and skip empty/blank lines (e.g. trailing '>' markers). --- lua/markview/parsers/markdown.lua | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lua/markview/parsers/markdown.lua b/lua/markview/parsers/markdown.lua index 53e19db..795d982 100644 --- a/lua/markview/parsers/markdown.lua +++ b/lua/markview/parsers/markdown.lua @@ -885,8 +885,19 @@ markdown.table = function (_, _, text, range) end for l, line in ipairs(text) do + --- Strip block_continuation prefixes(e.g. `> `) + --- from lines after the first. + --- `get_node_text()` only applies col_start to line 1. + if l > 1 then + line = line:sub(range.col_start + 1); + end + local row_text = line; + if row_text == "" or row_text:match("^%s*$") then + goto continue; + end + if l == 1 then header = line_processor(row_text); elseif l == 2 then @@ -917,6 +928,8 @@ markdown.table = function (_, _, text, range) else table.insert(rows, line_processor(row_text)) end + + ::continue:: end local top_border, border_overlap = overlap(range.row_start); From 22fd50a39f464c4a5378fea07b7612f15029f74a Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Thu, 19 Mar 2026 09:52:55 +0100 Subject: [PATCH 09/14] test: add markdown stress test for table rendering Covers feature matrix tables, alignment torture (left/center/right), nested structures (lists, blockquotes, code blocks), inline chaos, fenced blocks, horizontal rules, single-column tables, tables inside blockquotes, and math blocks. --- test/stress.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 test/stress.md diff --git a/test/stress.md b/test/stress.md new file mode 100644 index 0000000..c8d7737 --- /dev/null +++ b/test/stress.md @@ -0,0 +1,109 @@ +Here's a stress test for your markdown renderer: + +--- + +### Feature Matrix + + +| Feature | Status | Docs | +|---------|--------|------| +| **Bold** & *Italic* | ✅ Done | [spec](https://spec.commonmark.org/0.31.2/#emphasis-and-strong-emphasis-with-asterisks-and-underscores-rule-1-through-17) | +| ~~Strikethrough~~ | ⚠️ Partial | [GFM](https://github.github.com/gfm/#strikethrough-extension-with-tildes-and-double-tildes-for-del-elements) | +| `inline code` | ✅ Done | [ref](https://spec.commonmark.org/0.31.2/#code-spans-backtick-strings-and-their-matching-rules-for-inline-code) | +| Nested lists | 🔧 WIP | [deep](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#nested-lists-ordered-and-unordered-mixing-indentation-levels) | + +### Alignment Torture + +| Left | Center | Right | Mixed | +|:-----|:------:|------:|-------| +| `vim.api` | **strong** | 42 | [API](https://neovim.io/doc/user/api.html#nvim_buf_set_lines()-nvim_buf_get_lines()-and-other-buffer-manipulation-functions) | +| `vim.lsp` | *emphasis* | 3.14 | [LSP](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion_resolve_and_other_request_types) | +| `vim.treesitter` | ***both*** | 0xDEAD | [TS](https://tree-sitter.github.io/tree-sitter/using-parsers/queries/pattern-matching-with-predicates-and-anchors#the-match-predicate) | + +### Nested Structures + +1. **First level** + - Bullet with `code` and [a link](https://example.com) + - Another bullet + 1. Ordered inside unordered + 2. With a table inside: + + | Key | Val | + |-----|-----| + | `a` | 1 | + + 3. Back to the list + - > A blockquote inside a list item + > spanning multiple lines +2. **Second level** — with a long code block: + + ````lua + local M = {} + -- nested code fences should survive + function M.setup(opts) + opts = vim.tbl_deep_extend("force", { + enabled = true, + style = { bold = true, italic = false }, + }, opts or {}) + return opts + end + return M + ```` + +3. ***Third*** with a task list: + - [x] Completed task + - [ ] Pending task + - [ ] Another one + +### Inline Chaos + +This paragraph has **bold**, *italic*, ***bold-italic***, `inline code`, ~~deleted~~, and [a very descriptively titled link](https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz#some-very-long-anchor-fragment-that-keeps-going-and-going-forever) all in one sentence. + +### Fenced Blocks Parade + +````python +# Python +def f(x: int) -> dict[str, list[int]]: + return {"result": [i**2 for i in range(x)]} +```` + +````bash +# Shell with pipes +cat /proc/cpuinfo | grep -i "model name" | head -1 | awk -F: '{print $2}' +```` + +````json +{ + "nested": { "deep": { "value": [1, 2, 3] } }, + "escaped": "quotes \"inside\" strings" +} +```` + +### Horizontal Rules vs. Table Edges + +--- + +| Single col | +|------------| +| lonely | + +--- + +> ### Blockquote with heading +> And a table: +> +> | A | B | +> |---|---| +> | 1 | 2 | +> +> And some `code` too. + +### Math-ish (if supported) + +Euler: $e^{i\pi} + 1 = 0$ + +$$ +\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6} +$$ + +--- From aa0449e645df697cfee4a34f25fd123d42558c97 Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Thu, 19 Mar 2026 10:28:45 +0100 Subject: [PATCH 10/14] test: add regression examples for visual rendering --- test/regression-examples.md | 229 ++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 test/regression-examples.md diff --git a/test/regression-examples.md b/test/regression-examples.md new file mode 100644 index 0000000..46a4af6 --- /dev/null +++ b/test/regression-examples.md @@ -0,0 +1,229 @@ +# Regression Testing Examples + +Instructions: Open this file with `set wrap` enabled. Walk through each +numbered section matching the regression matrix. + +--- + +## 1 — Concealed content: table with long URLs renders + +The table below has long URLs that get concealed. It should still render +as a proper table (borders, padding, alignment) — not fall back to raw text. + +| Feature | Status | Docs | +|---------|--------|------| +| **Bold** | ✅ Done | [spec](https://spec.commonmark.org/0.31.2/#emphasis-and-strong-emphasis-with-asterisks-and-underscores-rule-1-through-17) | +| `code` | ⚠️ Partial | [GFM](https://github.github.com/gfm/#strikethrough-extension-with-tildes-and-double-tildes-for-del-elements) | +| Nested lists | 🔧 WIP | [deep](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#nested-lists-ordered-and-unordered-mixing-indentation-levels) | + +--- + +## 2 — Wrap continuation lines show table borders + +Shrink the window width until the table above wraps. Continuation lines +should show `│...│` table borders — not raw text leaking through. + +(Use the same table from §1 above. Narrow the window to ~60 columns.) + +--- + +## 3 — Strikethrough doesn't inflate column widths + +The `~~Strikethrough~~` column should be the same visual width as the text +without the `~~` markers. Compare column widths with and without. + +| Style | Example | Notes | +|-------|---------|-------| +| Plain | hello | baseline | +| ~~Strikethrough~~ | ~~deleted~~ | width should match "deleted" | +| **Bold** | **strong** | width should match "strong" | + +--- + +## 4 — Table borders use MarkviewTable* highlights + +All table borders (`│`, `─`, `┼`, `╭`, `╮`, etc.) should use `MarkviewTableBorder` +or `MarkviewTableHeader` highlight groups — not treesitter `@punctuation.special`. + +Inspect the borders of this table: + +| A | B | +|---|---| +| 1 | 2 | + +--- + +## 5 — Right border on first screen row of wrapping lines + +When a table row wraps, the **first** screen row of that wrapped line should +still have a right border `│` at the correct position. + +(Use the long-URL table from §1. Shrink window until rows wrap. Check the +right edge of the first screen row of each wrapping line.) + +--- + +## 6 — Top/bottom border indent for nested tables (list context) + +The table below is inside an ordered list. The top and bottom borders should +be indented to align with the table content — not flush-left. + +1. Here is an item with a nested table: + + | Key | Val | + |-----|-----| + | `x` | 42 | + | `y` | 99 | + +2. Another list item. + +And a deeper nesting: + +1. Level 1 + - Level 2 + 1. Level 3 table: + + | A | B | C | + |---|---|---| + | 1 | 2 | 3 | + + 2. Back to list. + +--- + +## 7 — Separator decorations stable in hybrid mode + +Move your cursor in and out of the table below. The separator row decorations +(`─`, `╶`, `┼`) should **not** swap order when the cursor enters/leaves. + +| Left | Center | Right | +|:-----|:------:|------:| +| aaa | bbb | ccc | +| ddd | eee | fff | + +Also test with cursor on this heading, then move into the table above. + +--- + +## 8 — Tables inside blockquotes render fully + +The table inside this blockquote should render the separator row AND all +data rows — not just the header. + +> | Name | Value | +> |------|-------| +> | alpha | 1 | +> | beta | 2 | +> | gamma | 3 | + +Nested blockquote: + +> > | X | Y | +> > |---|---| +> > | a | b | + +--- + +## 9 — Simple table renders correctly (nowrap) + +Set `nowrap`. This simple table should render with proper borders. + +| One | +|-----| +| 1 | + +And a wider one: + +| Col A | Col B | Col C | Col D | +|-------|-------|-------|-------| +| foo | bar | baz | qux | +| alpha | beta | gamma | delta | + +--- + +## 10 — Alignment markers render correctly + +Each column should show the correct alignment decoration in the separator row. + +| Default | Left | Center | Right | +|---------|:-----|:------:|------:| +| none | left | center | right | +| aaa | bbb | ccc | ddd | + +--- + +## 11 — Blockquote borders alongside table borders + +Both the blockquote border (`▋`) and table borders (`│`) should be visible +side by side. + +> | Animal | Sound | +> |--------|-------| +> | Cat | Meow | +> | Dog | Woof | + +--- + +## 12 — Hybrid mode: cursor-line un-renders/re-renders cleanly + +Move your cursor row-by-row through this table. Each row should un-render +when the cursor is on it (showing raw markdown) and re-render when the +cursor leaves. + +| Language | Typing | Speed | +|----------|--------|-------| +| Lua | dynamic | fast | +| Rust | static | fast | +| Python | dynamic | moderate | +| C | static | very fast | + +--- + +## 13 — Code blocks inside lists render correctly + +The code block below is nested inside a list. It should render with proper +syntax highlighting and code-block decorations. + +1. **First item** + - Sub-item with code: + + ```lua + local M = {} + function M.setup(opts) + return vim.tbl_deep_extend("force", {}, opts or {}) + end + return M + ``` + + - Another sub-item + +2. **Second item** + +--- + +## 14 — Headings, horizontal rules, inline formatting unaffected + +### This is an H3 + +#### This is an H4 + +##### This is an H5 + +--- + +Inline formatting: **bold**, *italic*, ***bold-italic***, `inline code`, +~~strikethrough~~, and [a link](https://example.com). + +A horizontal rule below: + +--- + +And another: + +*** + +--- + +## End of regression examples + +Replace ☐ with ✅ or ❌ in `test/regression-matrix.md` as you test each case. From 0dde47da98d11e50033b86464dd7bb0c04fb7037 Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Fri, 20 Mar 2026 08:07:17 +0100 Subject: [PATCH 11/14] refactor(markdown): replace screenpos with nvim_win_text_height for wrap detection Use nvim_win_text_height and virtcol2col instead of screenpos to locate wrap line boundaries in table continuation border rendering. This avoids reliance on screen state and works correctly when the window is not visible or during deferred rendering. --- lua/markview/renderers/markdown.lua | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/lua/markview/renderers/markdown.lua b/lua/markview/renderers/markdown.lua index 3ae875b..3f8f3ca 100644 --- a/lua/markview/renderers/markdown.lua +++ b/lua/markview/renderers/markdown.lua @@ -2647,10 +2647,10 @@ markdown.__table = function (buffer, item) start_row = row, end_row = row }); - if height.all > 1 then - local line_len = #(vim.api.nvim_buf_get_lines(buffer, row, row + 1, false)[1] or ""); + if height.all > 1 then + local line = vim.api.nvim_buf_get_lines(buffer, row, row + 1, false)[1] or ""; local lnum = row + 1; - local first_screen_row = vim.fn.screenpos(win, lnum, 1).row; + local total_vcol = vim.fn.strdisplaywidth(line); --- Place right border on first screen row. --- The concealed right pipe wraps to a continuation line, @@ -2665,23 +2665,27 @@ markdown.__table = function (buffer, item) end for w = 1, height.all - 1 do - local target_row = first_screen_row + w; - - --- Binary search for the first byte on `target_row`. - local lo, hi = 1, line_len; + --- Binary search for the first vcol on wrap line `w`. + local lo, hi = 1, total_vcol; while lo < hi do local mid = math.floor((lo + hi) / 2); - if vim.fn.screenpos(win, lnum, mid).row < target_row then + if vim.api.nvim_win_text_height(win, { + start_row = row, end_row = row, + start_vcol = 0, end_vcol = mid, + }).all <= w then lo = mid + 1; else hi = mid; end end - if lo <= line_len then - vim.api.nvim_buf_set_extmark(buffer, markdown.ns, row, lo - 1, { + --- Convert vcol to byte column. + local byte_col = vim.fn.virtcol2col(win, lnum, lo); + + if byte_col >= 1 then + vim.api.nvim_buf_set_extmark(buffer, markdown.ns, row, byte_col - 1, { undo_restore = false, invalidate = true, virt_text = continuation_vt, virt_text_win_col = 0, From cf766dfd9a947196a0aad37e692ab1da709e97d9 Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Fri, 20 Mar 2026 10:06:02 +0100 Subject: [PATCH 12/14] fix(renderer): use analytical wrap-line placement for table borders Replace the binary-search approach for placing continuation borders on wrapped table lines with an analytical walk over the parsed table structure. The old method relied on virtcol, which ignores extmark conceal and caused misaligned borders when cells contained concealed elements (e.g. CommonMark links with long URLs). Additionally: - Remove unused vim_width tracking across three column-width loops - Fix wrap detection to account for textoff (sign/number columns) and compare rendered table width against the usable text area - Store col_widths on the item for use in the post_render phase - Accept minor wrap artefacts from unconcealed soft-wrap rather than bailing out of rendering entirely --- lua/markview/renderers/markdown.lua | 138 ++++++++++++++++------------ 1 file changed, 80 insertions(+), 58 deletions(-) diff --git a/lua/markview/renderers/markdown.lua b/lua/markview/renderers/markdown.lua index 3f8f3ca..9087528 100644 --- a/lua/markview/renderers/markdown.lua +++ b/lua/markview/renderers/markdown.lua @@ -1567,9 +1567,6 @@ markdown.table = function (buffer, item) rows = {} }; - ---@type integer[] Invisible width used for text wrapping in Neovim. - local vim_width = {}; - ---@type integer Current column number. local c = 1; @@ -1584,14 +1581,6 @@ markdown.table = function (buffer, item) col_widths[c] = o; end - local vim_col_width = vim.fn.strdisplaywidth(col.text); - - if not vim_width[c] then - vim_width[c] = vim_col_width; - elseif vim_col_width > vim_width[c] then - vim_width[c] = vim_col_width; - end - c = c + 1; end end @@ -1606,14 +1595,6 @@ markdown.table = function (buffer, item) col_widths[c] = o; end - local vim_col_width = vim.fn.strdisplaywidth(col.text); - - if not vim_width[c] then - vim_width[c] = vim_col_width; - elseif vim_col_width > vim_width[c] then - vim_width[c] = vim_col_width; - end - c = c + 1; end end @@ -1633,14 +1614,6 @@ markdown.table = function (buffer, item) col_widths[c] = o; end - local vim_col_width = vim.fn.strdisplaywidth(col.text); - - if not vim_width[c] then - vim_width[c] = vim_col_width; - elseif vim_col_width > vim_width[c] then - vim_width[c] = vim_col_width; - end - c = c + 1; end end @@ -1648,7 +1621,8 @@ markdown.table = function (buffer, item) if is_wrapped == true then local win = utils.buf_getwin(buffer); - local width = vim.api.nvim_win_get_width(win); + local textoff = vim.fn.getwininfo(win)[1].textoff; + local text_width = vim.api.nvim_win_get_width(win) - textoff; local table_width = 1; @@ -1656,11 +1630,22 @@ markdown.table = function (buffer, item) table_width = table_width + 1 + col; end - if table_width >= width * 0.9 then - --- Most likely the text was wrapped somewhere. - --- TODO, Check if a more accurate(& faster) method exists or not. + if table_width >= text_width then + --- The rendered table is at least as wide as the usable + --- text area, so it will wrap and the border layout would + --- be broken. Bail out of rendering entirely. return; end + + --- NOTE: We intentionally do NOT check the raw (unconcealed) + --- line width here. Neovim's soft-wrap calculation ignores + --- conceal, so a line with a long URL may wrap internally + --- even though the *rendered* table fits. Bailing out in + --- that case would prevent rendering of any table that + --- contains wide inline elements (links, images, …) which + --- defeats the purpose of the preview. Accept the possible + --- wrap artefact — a rendered table with a minor visual + --- glitch is strictly better than no rendering at all. end ---@type markview.config.markdown.tables.parts @@ -2503,6 +2488,7 @@ markdown.table = function (buffer, item) local right_border, right_hl = get_border("row", 3); item.__right_border_vt = { { right_border, right_hl } }; item.__table_width = utils.virt_len(continuation_vt); + item.__col_widths = col_widths; --- Register for post_render so __table runs after inline extmarks. table.insert(markdown.cache, item); @@ -2641,17 +2627,19 @@ markdown.__table = function (buffer, item) local range = item.range; + local col_widths = item.__col_widths; + + --- Compute the window's text-area width (excluding sign/number columns). + local textoff = vim.fn.getwininfo(win)[1].textoff; + local text_width = vim.api.nvim_win_get_width(win) - textoff; + vim.api.nvim_win_call(win, function() for row = range.row_start, range.row_end - 1 do local height = vim.api.nvim_win_text_height(win, { start_row = row, end_row = row }); - if height.all > 1 then - local line = vim.api.nvim_buf_get_lines(buffer, row, row + 1, false)[1] or ""; - local lnum = row + 1; - local total_vcol = vim.fn.strdisplaywidth(line); - + if height.all > 1 then --- Place right border on first screen row. --- The concealed right pipe wraps to a continuation line, --- leaving the first screen row without a right border. @@ -2664,33 +2652,67 @@ markdown.__table = function (buffer, item) }); end - for w = 1, height.all - 1 do - --- Binary search for the first vcol on wrap line `w`. - local lo, hi = 1, total_vcol; - - while lo < hi do - local mid = math.floor((lo + hi) / 2); + --- Find wrap-break byte positions analytically by walking + --- the parsed table structure. The rendered width of each + --- element is known: separators = 1 col (│), columns = + --- col_widths[c]. We accumulate display width and record + --- the byte at the start of each element that crosses a + --- wrap boundary (multiples of text_width). + --- + --- Unlike a binary search over virtual columns, this + --- approach is immune to the coordinate-space mismatch + --- between virtcol (which ignores extmark conceal) and + --- nvim_win_text_height (which accounts for it). See + --- CommonMark §6.7 links inside table cells for a case + --- where concealed URLs broke the old binary search. + local parts; + if row == range.row_start then + parts = item.header; + elseif row == range.row_start + 1 then + parts = item.separator; + else + local ri = row - (range.row_start + 2) + 1; + parts = item.rows[ri]; + end - if vim.api.nvim_win_text_height(win, { - start_row = row, end_row = row, - start_vcol = 0, end_vcol = mid, - }).all <= w then - lo = mid + 1; + if parts and col_widths then + local disp = 0; --- cumulative display columns + local wrap_line = 1; --- next wrap line to place + local cc = 1; --- column counter + + for _, part in ipairs(parts) do + local elem_width; + if part.class == "separator" then + elem_width = 1; + elseif part.class == "column" then + elem_width = col_widths[cc] or 0; + cc = cc + 1; else - hi = mid; + goto continue; end - end - --- Convert vcol to byte column. - local byte_col = vim.fn.virtcol2col(win, lnum, lo); + --- Check if this element spans a wrap boundary. + while wrap_line <= height.all - 1 + and disp + elem_width >= text_width * (wrap_line) + do + --- The byte at the start of this element is + --- guaranteed to be visible (not inside an + --- extmark-concealed URL). Using it as the + --- anchor ensures the overlay lands on the + --- correct screen row. + local anchor = range.col_start + part.col_start; + vim.api.nvim_buf_set_extmark(buffer, markdown.ns, row, anchor, { + undo_restore = false, invalidate = true, + virt_text = continuation_vt, + virt_text_win_col = 0, + hl_mode = "combine", + }); + wrap_line = wrap_line + 1; + end - if byte_col >= 1 then - vim.api.nvim_buf_set_extmark(buffer, markdown.ns, row, byte_col - 1, { - undo_restore = false, invalidate = true, - virt_text = continuation_vt, - virt_text_win_col = 0, - hl_mode = "combine", - }); + disp = disp + elem_width; + + ::continue:: end end end From edb0940acb705bf646419cfdf9aace16847fbcea Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Fri, 20 Mar 2026 10:56:43 +0100 Subject: [PATCH 13/14] fix(renderer): use binary search over vcols for wrap continuation borders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the analytical walk (which used rendered/concealed column widths) with a binary search using nvim_win_text_height and vcol parameters. The analytical walk underestimated wrap boundaries because Neovim wraps based on raw text width + inline virt_text, ignoring extmark conceal — so concealed URLs still count towards the wrap width. nvim_win_text_height with start_vcol/end_vcol operates in the same coordinate space Neovim uses for wrapping, making it the correct predicate for locating wrap boundaries. - Use height.all * text_width as upper bound (safe, exceeds the effective wrap width including inline virt_text additions) - Convert vcol to byte position via virtcol2col for extmark anchor - Remove unused __col_widths storage from table items --- lua/markview/renderers/markdown.lua | 98 ++++++++++++----------------- 1 file changed, 39 insertions(+), 59 deletions(-) diff --git a/lua/markview/renderers/markdown.lua b/lua/markview/renderers/markdown.lua index 9087528..f5a9f97 100644 --- a/lua/markview/renderers/markdown.lua +++ b/lua/markview/renderers/markdown.lua @@ -2488,7 +2488,6 @@ markdown.table = function (buffer, item) local right_border, right_hl = get_border("row", 3); item.__right_border_vt = { { right_border, right_hl } }; item.__table_width = utils.virt_len(continuation_vt); - item.__col_widths = col_widths; --- Register for post_render so __table runs after inline extmarks. table.insert(markdown.cache, item); @@ -2627,8 +2626,6 @@ markdown.__table = function (buffer, item) local range = item.range; - local col_widths = item.__col_widths; - --- Compute the window's text-area width (excluding sign/number columns). local textoff = vim.fn.getwininfo(win)[1].textoff; local text_width = vim.api.nvim_win_get_width(win) - textoff; @@ -2652,67 +2649,50 @@ markdown.__table = function (buffer, item) }); end - --- Find wrap-break byte positions analytically by walking - --- the parsed table structure. The rendered width of each - --- element is known: separators = 1 col (│), columns = - --- col_widths[c]. We accumulate display width and record - --- the byte at the start of each element that crosses a - --- wrap boundary (multiples of text_width). + --- Find the first byte on each continuation (wrapped) + --- screen row via binary search over virtual columns. --- - --- Unlike a binary search over virtual columns, this - --- approach is immune to the coordinate-space mismatch - --- between virtcol (which ignores extmark conceal) and - --- nvim_win_text_height (which accounts for it). See - --- CommonMark §6.7 links inside table cells for a case - --- where concealed URLs broke the old binary search. - local parts; - if row == range.row_start then - parts = item.header; - elseif row == range.row_start + 1 then - parts = item.separator; - else - local ri = row - (range.row_start + 2) + 1; - parts = item.rows[ri]; - end - - if parts and col_widths then - local disp = 0; --- cumulative display columns - local wrap_line = 1; --- next wrap line to place - local cc = 1; --- column counter - - for _, part in ipairs(parts) do - local elem_width; - if part.class == "separator" then - elem_width = 1; - elseif part.class == "column" then - elem_width = col_widths[cc] or 0; - cc = cc + 1; + --- nvim_win_text_height with start_vcol/end_vcol operates + --- in the same coordinate space Neovim uses for wrapping + --- (raw text width + inline virt_text, ignoring conceal). + --- This makes it the correct predicate for locating wrap + --- boundaries — unlike an analytical walk over rendered + --- widths, which underestimates when cells contain + --- concealed URLs that still count towards wrap width. + --- + --- Upper bound: height.all * text_width is guaranteed to + --- exceed the effective wrap width (including any inline + --- virt_text additions that push past strdisplaywidth). + local lnum = row + 1; + local hi_bound = height.all * text_width; + + for w = 1, height.all - 1 do + local lo, hi = 1, hi_bound; + + while lo < hi do + local mid = math.floor((lo + hi) / 2); + + if vim.api.nvim_win_text_height(win, { + start_row = row, end_row = row, + start_vcol = 0, end_vcol = mid, + }).all <= w then + lo = mid + 1; else - goto continue; - end - - --- Check if this element spans a wrap boundary. - while wrap_line <= height.all - 1 - and disp + elem_width >= text_width * (wrap_line) - do - --- The byte at the start of this element is - --- guaranteed to be visible (not inside an - --- extmark-concealed URL). Using it as the - --- anchor ensures the overlay lands on the - --- correct screen row. - local anchor = range.col_start + part.col_start; - vim.api.nvim_buf_set_extmark(buffer, markdown.ns, row, anchor, { - undo_restore = false, invalidate = true, - virt_text = continuation_vt, - virt_text_win_col = 0, - hl_mode = "combine", - }); - wrap_line = wrap_line + 1; + hi = mid; end + end - disp = disp + elem_width; + --- lo is the first vcol on wrap line w+1. + --- Convert to a byte column for the extmark anchor. + local byte_col = vim.fn.virtcol2col(win, lnum, lo); - ::continue:: + if byte_col >= 1 then + vim.api.nvim_buf_set_extmark(buffer, markdown.ns, row, byte_col - 1, { + undo_restore = false, invalidate = true, + virt_text = continuation_vt, + virt_text_win_col = 0, + hl_mode = "combine", + }); end end end From 7c6421acb632b70073f10f172b09f54724ebd91d Mon Sep 17 00:00:00 2001 From: Immanuel Haffner Date: Fri, 20 Mar 2026 11:52:01 +0100 Subject: [PATCH 14/14] fix(inline): remove hl_mode combine from link extmarks to prevent ghost underlines Remove hl_mode="combine" from the concealing extmarks in link_hyperlink and link_image. When a link URL is concealed, the virtual text highlight (e.g. underline) bled across every concealed byte, producing ghost underlines on phantom screen rows created by soft-wrap of the hidden text. Add inline conceal torture section to stress test. --- lua/markview/renderers/markdown_inline.lua | 11 +++++++---- test/stress.md | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/lua/markview/renderers/markdown_inline.lua b/lua/markview/renderers/markdown_inline.lua index b16c552..44ae71f 100644 --- a/lua/markview/renderers/markdown_inline.lua +++ b/lua/markview/renderers/markdown_inline.lua @@ -615,6 +615,11 @@ inline.link_hyperlink = function (buffer, item) hl_group = utils.set_hl(config.hl) }); + --- NOTE: hl_mode must NOT be "combine" here. This extmark conceals + --- the URL portion `](https://…)` which can span hundreds of bytes. + --- With "combine" the virt_text highlight (e.g. underline) bleeds + --- across every concealed byte, producing ghost underlines on the + --- phantom screen rows created by soft-wrap of the hidden text. vim.api.nvim_buf_set_extmark(buffer, inline.ns, r_label[3], r_label[4], { undo_restore = false, invalidate = true, end_row = range.row_end, @@ -626,8 +631,6 @@ inline.link_hyperlink = function (buffer, item) { config.padding_right or "", utils.set_hl(config.padding_right_hl or config.hl) }, { config.corner_right or "", utils.set_hl(config.corner_right_hl or config.hl) } }, - - hl_mode = "combine" }); if r_label[1] == r_label[3] then @@ -734,6 +737,8 @@ inline.link_image = function (buffer, item) hl_group = utils.set_hl(config.hl) }); + --- NOTE: hl_mode must NOT be "combine" here — same reason as link_hyperlink. + --- See the comment there for full explanation. vim.api.nvim_buf_set_extmark(buffer, inline.ns, r_label[3], r_label[4], { undo_restore = false, invalidate = true, end_row = range.row_end, @@ -745,8 +750,6 @@ inline.link_image = function (buffer, item) { config.padding_right or "", utils.set_hl(config.padding_right_hl or config.hl) }, { config.corner_right or "", utils.set_hl(config.corner_right_hl or config.hl) } }, - - hl_mode = "combine" }); if r_label[1] == r_label[3] then diff --git a/test/stress.md b/test/stress.md index c8d7737..559a245 100644 --- a/test/stress.md +++ b/test/stress.md @@ -12,6 +12,27 @@ Here's a stress test for your markdown renderer: | `inline code` | ✅ Done | [ref](https://spec.commonmark.org/0.31.2/#code-spans-backtick-strings-and-their-matching-rules-for-inline-code) | | Nested lists | 🔧 WIP | [deep](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#nested-lists-ordered-and-unordered-mixing-indentation-levels) | +### Inline Conceal Torture + +| Kind | Example | With long URL | +|------|---------|---------------| +| Hyperlink | [short](https://example.com) | [Neovim API reference](https://neovim.io/doc/user/api.html#nvim_buf_set_extmark()-nvim_buf_del_extmark()-nvim_buf_get_extmarks()-and-related-extmark-functions) | +| Image | ![icon](https://example.com/icon.svg) | ![screenshot of the full treesitter playground](https://raw.githubusercontent.com/nvim-treesitter/playground/master/assets/screenshot-with-custom-queries-and-hl-groups.png) | +| URI autolink | | | +| Email autolink | | | +| Inline code | `short` | `vim.api.nvim_buf_set_extmark(buffer, ns, row, col, opts)` | +| Highlight | ==marked== | ==this is a rather long highlighted span that should test wrapping== | +| Entity | & and < | & < > → ← ♥ ∞ — | +| Escaped | \* not bold \* | \* \[ \] \( \) \` \~ \\ \# \! | +| Emoji | :rocket: launch | :tada: :sparkles: :rocket: :fire: :bug: :memo: :bulb: :wrench: | +| Footnote | see [^1] | see [^long-descriptive-footnote-name-that-tests-width] | +| Bold + link | **[bold link](https://example.com)** | **[bold link with long URL](https://spec.commonmark.org/0.31.2/#emphasis-and-strong-emphasis-combined-with-links-and-images)** | +| Code + link | `code` then [link](https://a.co) | `vim.api.nvim_buf_set_extmark()` then [docs](https://neovim.io/doc/user/api.html#nvim_buf_set_extmark()-full-details) | +| Multi-conceal | **bold** `code` *italic* [lnk](https://x.co) | **bold** `code` *ital* ==hl== [lnk](https://neovim.io/doc/user/api.html#multi-conceal-stress-test-row) :rocket: | + +[^1]: A short footnote. +[^long-descriptive-footnote-name-that-tests-width]: This footnote has a very long reference label to test how concealment handles it in table cells. + ### Alignment Torture | Left | Center | Right | Mixed |