From bfc3edc8096111a442adf1dc065af0dd60063445 Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 3 Mar 2026 14:48:29 -0800 Subject: [PATCH 01/16] feat(table): add cell wrapping using virt lines --- lua/render-markdown/core/manager.lua | 24 ++ lua/render-markdown/render/markdown/table.lua | 386 +++++++++++++++++- lua/render-markdown/request/inline.lua | 15 +- lua/render-markdown/settings.lua | 13 + 4 files changed, 434 insertions(+), 4 deletions(-) diff --git a/lua/render-markdown/core/manager.lua b/lua/render-markdown/core/manager.lua index 39b66595..527dee09 100644 --- a/lua/render-markdown/core/manager.lua +++ b/lua/render-markdown/core/manager.lua @@ -38,6 +38,30 @@ function M.init() end end, }) + -- terminal / GUI resize — re-render all visible attached windows + vim.api.nvim_create_autocmd('VimResized', { + group = M.group, + callback = function(args) + for _, win in ipairs(vim.api.nvim_list_wins()) do + local buf = env.win.buf(win) + if M.attached(buf) and state.get(buf).enabled then + ui.update(buf, win, args.event, true) + end + end + end, + }) + -- wrap option toggled (:set wrap / :set nowrap) — re-render the current window + vim.api.nvim_create_autocmd('OptionSet', { + group = M.group, + pattern = 'wrap', + callback = function(args) + local win = vim.api.nvim_get_current_win() + local buf = env.win.buf(win) + if M.attached(buf) and state.get(buf).enabled then + ui.update(buf, win, args.event, true) + end + end, + }) end ---@param buf integer diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index d88c3526..cf6123eb 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -1,4 +1,6 @@ local Base = require('render-markdown.render.base') +local Line = require('render-markdown.lib.line') +local env = require('render-markdown.lib.env') local iter = require('render-markdown.lib.iter') local log = require('render-markdown.core.log') local str = require('render-markdown.lib.str') @@ -32,6 +34,8 @@ local Alignment = { ---@class render.md.table.row.Cell ---@field node render.md.Node +---@field start_col integer +---@field end_col integer ---@field width integer ---@field space render.md.table.cell.Space @@ -43,9 +47,15 @@ local Alignment = { ---@field pipes render.md.Node[] ---@field cells render.md.Node[] +---@class render.md.table.Layout +---@field wrap boolean +---@field col_widths integer[] +---@field row_heights integer[] + ---@class render.md.render.Table: render.md.Render ---@field private config render.md.table.Config ---@field private data render.md.table.Data +---@field private layout render.md.table.Layout local Render = setmetatable({}, Base) Render.__index = Render @@ -121,10 +131,161 @@ function Render:setup() end self.data = { layout = layout, delim = delim, cols = cols, rows = rows } + self.layout = self:compute_layout() + + -- When wrapping, update col widths so delimiter/border rendering + -- uses the capped widths (padding is included in col width). + if self.layout.wrap then + for i, w in ipairs(self.layout.col_widths) do + self.data.cols[i].width = w + 2 * self.config.padding + end + end return true end +---@private +---@return render.md.table.Layout +function Render:compute_layout() + local no_wrap = { wrap = false, col_widths = {}, row_heights = {} } + + -- Feature disabled when max_table_width is 0 (unset) + if self.config.max_table_width == 0 then + return no_wrap + end + -- Feature disabled when the window has line-wrap turned off — the table will + -- scroll horizontally so there are no continuation screen lines to fill, and + -- the col-redistribution logic would make things narrower for no reason. + if not env.win.get(self.context.win, 'wrap') then + return no_wrap + end + -- Only supported for padded/trimmed cell modes + if not vim.tbl_contains({ 'padded', 'trimmed' }, self.config.cell) then + return no_wrap + end + + local win_width = env.win.width(self.context.win) + local mtw = self.config.max_table_width + local available + if mtw < 0 then + -- Negative: characters from right edge + available = win_width + mtw + elseif mtw <= 1 then + -- Fraction of window width + available = math.floor(win_width * mtw) + else + -- Absolute character width + available = math.floor(mtw) + end + local num_cols = #self.data.cols + local padding = self.config.padding + + -- Total table display width = (num_cols+1) pipes + num_cols*(2*padding + text_width) + -- => text budget = available - (num_cols+1) - num_cols*2*padding + local overhead = (num_cols + 1) + (num_cols * 2 * padding) + local text_budget = available - overhead + + -- Collect the natural text-area width for each column (max content width across all rows) + local max_content = {} ---@type integer[] + for i = 1, num_cols do + max_content[i] = math.max( + self.data.cols[i].width - 2 * padding, + self.config.min_width + ) + end + for _, row in ipairs(self.data.rows) do + for i, cell in ipairs(row.cells) do + max_content[i] = math.max(max_content[i], cell.width) + end + end + + local total_natural = 0 + for _, w in ipairs(max_content) do + total_natural = total_natural + w + end + + -- Table already fits; use existing renderer + if total_natural <= text_budget then + return no_wrap + end + + -- Iterative redistribution: + -- Start with an equal share per column. Any column whose content fits + -- within that share gets locked at its natural width, freeing up budget + -- for the remaining columns. Repeat until stable. + local col_widths = {} ---@type integer[] + local locked = {} ---@type boolean[] + local locked_total = 0 + local locked_count = 0 + + local share = math.floor(text_budget / num_cols) + local changed = true + while changed do + changed = false + for i = 1, num_cols do + if not locked[i] and max_content[i] <= share then + locked[i] = true + locked_total = locked_total + max_content[i] + locked_count = locked_count + 1 + changed = true + end + end + if changed then + local free = num_cols - locked_count + if free > 0 then + share = math.floor((text_budget - locked_total) / free) + end + end + end + + -- Assign final widths: locked columns get their natural width, others get the share + for i = 1, num_cols do + col_widths[i] = locked[i] and max_content[i] or math.max(share, 1) + end + + -- Compute per-row heights based on how many lines each cell needs. + -- Also account for the raw (unrendered) buffer line wrapping: if the source + -- text is longer than the rendered text (e.g. a long concealed URL), the + -- buffer line may wrap onto more screen lines than the rendered content + -- requires. We must cover all of those screen lines with overlay marks, + -- so the effective height is max(rendered_lines, raw_screen_lines). + local row_heights = {} ---@type integer[] + local needs_wrap = false + for r, row in ipairs(self.data.rows) do + local max_lines = 1 + for i, cell in ipairs(row.cells) do + local w = col_widths[i] + if w > 0 and cell.width > w then + local lines = math.ceil(cell.width / w) + if lines > max_lines then + max_lines = lines + end + needs_wrap = true + end + end + -- Raw buffer line screen-wrap: ceil(display_width_of_source / win_width) + local buf_line = vim.api.nvim_buf_get_lines( + self.context.buf, + row.node.start_row, + row.node.start_row + 1, + false + )[1] or '' + local raw_screen_lines = + math.max(1, math.ceil(str.width(buf_line) / win_width)) + if raw_screen_lines > max_lines then + max_lines = raw_screen_lines + needs_wrap = true + end + row_heights[r] = max_lines + end + + if not needs_wrap then + return no_wrap + end + + return { wrap = true, col_widths = col_widths, row_heights = row_heights } +end + ---@private ---@param node render.md.Node ---@return render.md.table.Col[]? @@ -169,6 +330,100 @@ function Render.alignment(node) end end +---Compute display segments for a cell: raw text − concealed + injected, +---with treesitter highlight groups preserved. +---@private +---@param row integer +---@param start_col integer +---@param end_col integer +---@return render.md.mark.Line +function Render:cell_segments(row, start_col, end_col) + local raw_full = vim.api.nvim_buf_get_text( + self.context.buf, + row, + start_col, + row, + end_col, + {} + )[1] or '' + -- Trim cell padding upfront so we don't need post-processing + local lead = #(raw_full:match('^(%s*)') or '') + local trail = #(raw_full:match('(%s*)$') or '') + local raw = raw_full:sub(lead + 1, #raw_full - trail) + local base_col = start_col + lead + local injections = self.context.inline:range(row, start_col, end_col) + + local segments = {} ---@type render.md.mark.Line + local function push(text, hl) + if #text == 0 then + return + end + if #segments > 0 and segments[#segments][2] == hl then + segments[#segments][1] = segments[#segments][1] .. text + else + segments[#segments + 1] = { text, hl } + end + end + + local function push_injection(inj) + for _, seg in ipairs(inj.line) do + push(seg[1], seg[2] or '') + end + end + + local inj_i = 1 + -- Flush injections anchored in leading whitespace + while inj_i <= #injections and injections[inj_i].col < base_col do + push_injection(injections[inj_i]) + inj_i = inj_i + 1 + end + local bytes = vim.str_utf_pos(raw) + for k, start_byte in ipairs(bytes) do + local end_byte = k < #bytes and bytes[k + 1] - 1 or #raw + local abs_col = base_col + start_byte - 1 + -- Insert any injections anchored at this byte position + while inj_i <= #injections and injections[inj_i].col == abs_col do + push_injection(injections[inj_i]) + inj_i = inj_i + 1 + end + local char = raw:sub(start_byte, end_byte) + local body = { + start_row = row, + start_col = abs_col, + end_col = abs_col + end_byte - start_byte + 1, + text = char, + } + if self.context.conceal:get(body) <= 0 then + -- Use built-in API to get the treesitter highlight at this position + local hl = '' + for _, cap in + ipairs( + vim.treesitter.get_captures_at_pos( + self.context.buf, + row, + abs_col + ) + ) + do + if + cap.lang == 'markdown_inline' + and not vim.startswith(cap.capture, 'conceal') + then + hl = '@' .. cap.capture + end + end + push(char, hl) + end + end + -- Trailing injections after the last character + while inj_i <= #injections do + push_injection(injections[inj_i]) + inj_i = inj_i + 1 + end + return segments +end + +--TODO: Critical piece of code ---@private ---@param node render.md.Node ---@param num_cols integer @@ -190,6 +445,8 @@ function Render:parse_row(node, num_cols) assert(width >= 0, 'invalid table layout') cells[#cells + 1] = { node = cell, + start_col = start_col, + end_col = end_col, width = width, space = { -- gap between the cell start and the pipe start @@ -231,8 +488,14 @@ end ---@protected function Render:run() self:delimiter() - for _, row in ipairs(self.data.rows) do - self:row(row) + if self.layout.wrap then + for r, row in ipairs(self.data.rows) do + self:row_wrapped(row, r) + end + else + for _, row in ipairs(self.data.rows) do + self:row(row) + end end if self.config.border_enabled and self.data.layout.valid then self:border() @@ -333,6 +596,125 @@ function Render:row(row) end end +---@private +---@param row render.md.table.Row +---@param row_index integer +function Render:row_wrapped(row, row_index) + local height = self.layout.row_heights[row_index] + local header = row.node.type == 'pipe_table_header' + local highlight = header and self.config.head or self.config.row + local border_icon = self.config.border[10] + local padding = self.config.padding + local spaces = + math.max(str.spaces('start', row.node.text), row.node.start_col) + + -- Pre-compute display segments for each cell in this row + local cell_segs = {} ---@type render.md.mark.Line[] + for i, cell in ipairs(row.cells) do + cell_segs[i] = + self:cell_segments(row.node.start_row, cell.start_col, cell.end_col) + end + + local filler = self.config.filler + local function build_line(visual_line) + local line = self:line() + line:pad(spaces, filler) + for i, _ in ipairs(self.data.cols) do + local col_width = self.layout.col_widths[i] + line:text(border_icon, highlight) + line:pad(padding, filler) + local cell_line = Line.new(filler) + vim.list_extend(cell_line:get(), cell_segs[i] or {}) + local chunk = cell_line:sub( + visual_line * col_width + 1, + (visual_line + 1) * col_width + ) + line:extend(chunk) + line:pad(col_width - chunk:width(), filler) + line:pad(padding, filler) + end + line:text(border_icon, highlight) + return line + end + + local buf_line = vim.api.nvim_buf_get_lines( + self.context.buf, + row.node.start_row, + row.node.start_row + 1, + false + )[1] or '' + local win_width = env.win.width(self.context.win) + local buf_screen_lines = + math.max(1, math.ceil(str.width(buf_line) / win_width)) + + -- Line 0: conceal the source line then overlay the rendered row on top. + if #buf_line > 0 then + self.marks:add(self.config, 'table_border', row.node.start_row, 0, { + end_row = row.node.start_row, + end_col = #buf_line, + conceal = '', + }) + end + local first_line = build_line(0) + self.marks:add(self.config, 'table_border', row.node.start_row, 0, { + virt_text = first_line:get(), + virt_text_pos = 'overlay', + hl_mode = 'combine', + }) + + -- Lines 1..height-1: overlay buffer wrap continuations, then virt_lines. + if height > 1 then + local virt_lines = {} ---@type render.md.mark.Line[] + for vl = 1, height - 1 do + if vl < buf_screen_lines then + local byte_col = vim.fn.byteidx(buf_line, vl * win_width) + if byte_col < 0 then + byte_col = #buf_line + end + if #virt_lines > 0 then + self.marks:add( + self.config, + 'virtual_lines', + row.node.start_row, + 0, + { + virt_lines = virt_lines, + virt_lines_above = false, + } + ) + virt_lines = {} + end + self.marks:add( + self.config, + 'table_border', + row.node.start_row, + byte_col, + { + virt_text = build_line(vl):get(), + virt_text_pos = 'overlay', + hl_mode = 'combine', + } + ) + else + local vline = self:indent():line(true):extend(build_line(vl)) + virt_lines[#virt_lines + 1] = vline:get() + end + end + if #virt_lines > 0 then + self.marks:add( + self.config, + 'virtual_lines', + row.node.start_row, + 0, + { + virt_lines = virt_lines, + virt_lines_above = false, + } + ) + end + end +end + ---Use low priority to include pipe marks ---@private ---@param node render.md.Node diff --git a/lua/render-markdown/request/inline.lua b/lua/render-markdown/request/inline.lua index fcf5031f..b82ba94a 100644 --- a/lua/render-markdown/request/inline.lua +++ b/lua/render-markdown/request/inline.lua @@ -42,13 +42,24 @@ end ---@param body render.md.node.Body ---@return render.md.request.inline.Value[] function Inline:get(body) + return self:range(body.start_row, body.start_col, body.end_col) +end + +---@param row integer +---@param start_col integer +---@param end_col integer +---@return render.md.request.inline.Value[] +function Inline:range(row, start_col, end_col) local result = {} ---@type render.md.request.inline.Value[] - local values = self.values[body.start_row] or {} + local values = self.values[row] or {} for _, value in ipairs(values) do - if body.start_col <= value.col and body.end_col > value.col then + if start_col <= value.col and end_col > value.col then result[#result + 1] = value end end + table.sort(result, function(a, b) + return a.col < b.col + end) return result end diff --git a/lua/render-markdown/settings.lua b/lua/render-markdown/settings.lua index 4a4eb4b9..c66a5db8 100644 --- a/lua/render-markdown/settings.lua +++ b/lua/render-markdown/settings.lua @@ -1630,6 +1630,7 @@ M.pipe_table = {} ---@field cell_offset fun(ctx: render.md.table.cell.Context): integer ---@field padding integer ---@field min_width integer +---@field max_table_width number ---@field border string[] ---@field border_enabled boolean ---@field border_virtual boolean @@ -1690,6 +1691,17 @@ M.pipe_table.default = { padding = 1, -- Minimum column width to use for padded or trimmed cell. min_width = 0, + -- Maximum width of the rendered table. When a table's natural width exceeds + -- this limit, column widths are reduced proportionally and cell content that + -- no longer fits will wrap onto additional virtual lines. + -- Only applies to padded & trimmed cell modes, and only when the window + -- has 'wrap' enabled (otherwise the table scrolls horizontally). + -- Set to 0 to disable wrapping (default). + -- | 0 | disabled, no wrapping | + -- | 0.1–1.0 | fraction of window width, e.g. 0.8 = 80% | + -- | 2+ | absolute character width, e.g. 80 = 80 columns | + -- | < 0 | window width minus N, e.g. -10 = width minus 10 | + max_table_width = 0, -- Characters used to replace table border. -- Correspond to top(3), delimiter(3), bottom(3), vertical, & horizontal. -- stylua: ignore @@ -1725,6 +1737,7 @@ function M.pipe_table.schema() cell_offset = { type = 'function' }, padding = { type = 'number' }, min_width = { type = 'number' }, + max_table_width = { type = 'number' }, border = { list = { type = 'string' } }, border_enabled = { type = 'boolean' }, border_virtual = { type = 'boolean' }, From 6c186fed04fa0a70a0b5c2c0a213730c6cc12cae Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 3 Mar 2026 16:48:26 -0800 Subject: [PATCH 02/16] fix(table): prevent early return; enable wrapping when unrendered text is too long --- lua/render-markdown/render/markdown/table.lua | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index cf6123eb..db4e592f 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -204,11 +204,6 @@ function Render:compute_layout() total_natural = total_natural + w end - -- Table already fits; use existing renderer - if total_natural <= text_budget then - return no_wrap - end - -- Iterative redistribution: -- Start with an equal share per column. Any column whose content fits -- within that share gets locked at its natural width, freeing up budget From fae152f60b4fe98b7b2068ccf190d4c857f09112 Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 10 Mar 2026 15:23:03 -0700 Subject: [PATCH 03/16] fix(table): remove unused autocmd VimResized --- lua/render-markdown/core/manager.lua | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lua/render-markdown/core/manager.lua b/lua/render-markdown/core/manager.lua index 527dee09..250056c8 100644 --- a/lua/render-markdown/core/manager.lua +++ b/lua/render-markdown/core/manager.lua @@ -38,18 +38,6 @@ function M.init() end end, }) - -- terminal / GUI resize — re-render all visible attached windows - vim.api.nvim_create_autocmd('VimResized', { - group = M.group, - callback = function(args) - for _, win in ipairs(vim.api.nvim_list_wins()) do - local buf = env.win.buf(win) - if M.attached(buf) and state.get(buf).enabled then - ui.update(buf, win, args.event, true) - end - end - end, - }) -- wrap option toggled (:set wrap / :set nowrap) — re-render the current window vim.api.nvim_create_autocmd('OptionSet', { group = M.group, From 9f4d8a0ad8978e34605535eeca49309b2e775bea Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 10 Mar 2026 15:23:28 -0700 Subject: [PATCH 04/16] fix(table): remove unused var fix_natural --- lua/render-markdown/render/markdown/table.lua | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index db4e592f..402ab5f1 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -199,11 +199,6 @@ function Render:compute_layout() end end - local total_natural = 0 - for _, w in ipairs(max_content) do - total_natural = total_natural + w - end - -- Iterative redistribution: -- Start with an equal share per column. Any column whose content fits -- within that share gets locked at its natural width, freeing up budget From 499d48b3d34239dbab746a7463ddf2662d02aaec Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 10 Mar 2026 15:25:56 -0700 Subject: [PATCH 05/16] fix(table): remove unnecessary line height check --- lua/render-markdown/render/markdown/table.lua | 62 ++++++++----------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index 402ab5f1..433cdb11 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -653,56 +653,48 @@ function Render:row_wrapped(row, row_index) }) -- Lines 1..height-1: overlay buffer wrap continuations, then virt_lines. - if height > 1 then - local virt_lines = {} ---@type render.md.mark.Line[] - for vl = 1, height - 1 do - if vl < buf_screen_lines then - local byte_col = vim.fn.byteidx(buf_line, vl * win_width) - if byte_col < 0 then - byte_col = #buf_line - end - if #virt_lines > 0 then - self.marks:add( - self.config, - 'virtual_lines', - row.node.start_row, - 0, - { - virt_lines = virt_lines, - virt_lines_above = false, - } - ) - virt_lines = {} - end + local virt_lines = {} ---@type render.md.mark.Line[] + for vl = 1, height - 1 do + if vl < buf_screen_lines then + local byte_col = vim.fn.byteidx(buf_line, vl * win_width) + if byte_col < 0 then + byte_col = #buf_line + end + if #virt_lines > 0 then self.marks:add( self.config, - 'table_border', + 'virtual_lines', row.node.start_row, - byte_col, + 0, { - virt_text = build_line(vl):get(), - virt_text_pos = 'overlay', - hl_mode = 'combine', + virt_lines = virt_lines, + virt_lines_above = false, } ) - else - local vline = self:indent():line(true):extend(build_line(vl)) - virt_lines[#virt_lines + 1] = vline:get() + virt_lines = {} end - end - if #virt_lines > 0 then self.marks:add( self.config, - 'virtual_lines', + 'table_border', row.node.start_row, - 0, + byte_col, { - virt_lines = virt_lines, - virt_lines_above = false, + virt_text = build_line(vl):get(), + virt_text_pos = 'overlay', + hl_mode = 'combine', } ) + else + local vline = self:indent():line(true):extend(build_line(vl)) + virt_lines[#virt_lines + 1] = vline:get() end end + if #virt_lines > 0 then + self.marks:add(self.config, 'virtual_lines', row.node.start_row, 0, { + virt_lines = virt_lines, + virt_lines_above = false, + }) + end end ---Use low priority to include pipe marks From 414051e1386063d681cf1f5803435015f665d9c0 Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 10 Mar 2026 17:19:53 -0700 Subject: [PATCH 06/16] feat(table): render segments takes node object; simpler interface --- lua/render-markdown/render/markdown/table.lua | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index 433cdb11..56a93c39 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -323,23 +323,16 @@ end ---Compute display segments for a cell: raw text − concealed + injected, ---with treesitter highlight groups preserved. ---@private ----@param row integer ----@param start_col integer ----@param end_col integer +---@param node render.md.Node ---@return render.md.mark.Line -function Render:cell_segments(row, start_col, end_col) - local raw_full = vim.api.nvim_buf_get_text( - self.context.buf, - row, - start_col, - row, - end_col, - {} - )[1] or '' - -- Trim cell padding upfront so we don't need post-processing - local lead = #(raw_full:match('^(%s*)') or '') - local trail = #(raw_full:match('(%s*)$') or '') - local raw = raw_full:sub(lead + 1, #raw_full - trail) +function Render:cell_segments(node) + local row = node.start_row + local start_col = node.start_col + local end_col = node.end_col + + local lead = #(node.text:match('^(%s*)') or '') + local trail = #(node.text:match('(%s*)$') or '') + local raw = node.text:sub(lead + 1, #node.text - trail) local base_col = start_col + lead local injections = self.context.inline:range(row, start_col, end_col) @@ -601,8 +594,7 @@ function Render:row_wrapped(row, row_index) -- Pre-compute display segments for each cell in this row local cell_segs = {} ---@type render.md.mark.Line[] for i, cell in ipairs(row.cells) do - cell_segs[i] = - self:cell_segments(row.node.start_row, cell.start_col, cell.end_col) + cell_segs[i] = self:cell_segments(cell.node) end local filler = self.config.filler From 372c060d9380aa0d467f92084483bb632861923c Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 10 Mar 2026 17:33:37 -0700 Subject: [PATCH 07/16] feat(table): replace nvim_buf_get_lines with node:line --- lua/render-markdown/render/markdown/table.lua | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index 56a93c39..66f28c26 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -254,14 +254,10 @@ function Render:compute_layout() end end -- Raw buffer line screen-wrap: ceil(display_width_of_source / win_width) - local buf_line = vim.api.nvim_buf_get_lines( - self.context.buf, - row.node.start_row, - row.node.start_row + 1, - false - )[1] or '' + local _, line = row.node:line('first', 0) + line = line or '' local raw_screen_lines = - math.max(1, math.ceil(str.width(buf_line) / win_width)) + math.max(1, math.ceil(str.width(line) / win_width)) if raw_screen_lines > max_lines then max_lines = raw_screen_lines needs_wrap = true @@ -619,12 +615,8 @@ function Render:row_wrapped(row, row_index) return line end - local buf_line = vim.api.nvim_buf_get_lines( - self.context.buf, - row.node.start_row, - row.node.start_row + 1, - false - )[1] or '' + local _, buf_line = row.node:line('first', 0) + buf_line = buf_line or '' local win_width = env.win.width(self.context.win) local buf_screen_lines = math.max(1, math.ceil(str.width(buf_line) / win_width)) From e6cd1523f82746fcca9099b98f2149f47c512d08 Mon Sep 17 00:00:00 2001 From: Oula Kuuva Date: Fri, 5 Jun 2026 21:04:12 +0300 Subject: [PATCH 08/16] fix(table): wrapped eof bottom border --- lua/render-markdown/render/markdown/table.lua | 7 +++++- tests/table_spec.lua | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index 66f28c26..acfb3948 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -760,7 +760,12 @@ function Render:border() virt_text_pos = 'overlay', }) else - self.marks:add(self.config, 'virtual_lines', node.start_row, 0, { + local col = 0 + if not above and self.layout.wrap then + -- Place after wrapped row virtual lines at column 0. + col = node.end_col + end + self.marks:add(self.config, 'virtual_lines', node.start_row, col, { virt_lines = { self:indent():line(true):extend(line):get() }, virt_lines_above = above, }) diff --git a/tests/table_spec.lua b/tests/table_spec.lua index 8eab6e9d..16eb446d 100644 --- a/tests/table_spec.lua +++ b/tests/table_spec.lua @@ -279,4 +279,26 @@ describe('table', function() '└───────────┴───────────┘', }) end) + + it('wrapped eof bottom border', function() + util.setup.text({ + '', + '| ID | Title | Status |', + '| -- | ----- | ------ |', + '| 1 | This sentence is long enough to wrap across several rendered table lines without needing a trailing blank line. | Open |', + }, { + pipe_table = { max_table_width = 60 }, + win_options = { wrap = { default = false, rendered = true } }, + }) + + util.assert_screen({ + '┌──────┬────────────────────────────────────────┬──────────┐', + '│ ID │ Title │ Status │', + '├──────┼────────────────────────────────────────┼──────────┤', + '│ 1 │ This sentence is long enough to wrap a │ Open │', + '│ │ cross several rendered table lines wit │ │', + '│ │ hout needing a trailing blank line. │ │', + '└──────┴────────────────────────────────────────┴──────────┘', + }) + end) end) From d973ec1ad5ebfd1c84b8a4bad7748a9f491cc0ab Mon Sep 17 00:00:00 2001 From: Oula Kuuva Date: Fri, 5 Jun 2026 21:51:54 +0300 Subject: [PATCH 09/16] fix(table): compute wrapped widths from content --- lua/render-markdown/render/markdown/table.lua | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index acfb3948..c27adaba 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -17,6 +17,7 @@ local str = require('render-markdown.lib.str') ---@class render.md.table.Col ---@field width integer +---@field delimiter_width integer ---@field alignment render.md.table.col.Alignment ---@enum render.md.table.col.Alignment @@ -185,17 +186,20 @@ function Render:compute_layout() local overhead = (num_cols + 1) + (num_cols * 2 * padding) local text_budget = available - overhead + ---@param cell render.md.table.row.Cell + ---@return integer + local function content_width(cell) + return math.max(cell.width - cell.space.left - cell.space.right, 0) + end + -- Collect the natural text-area width for each column (max content width across all rows) local max_content = {} ---@type integer[] for i = 1, num_cols do - max_content[i] = math.max( - self.data.cols[i].width - 2 * padding, - self.config.min_width - ) + max_content[i] = self.data.cols[i].delimiter_width end for _, row in ipairs(self.data.rows) do for i, cell in ipairs(row.cells) do - max_content[i] = math.max(max_content[i], cell.width) + max_content[i] = math.max(max_content[i], content_width(cell)) end end @@ -233,35 +237,26 @@ function Render:compute_layout() col_widths[i] = locked[i] and max_content[i] or math.max(share, 1) end - -- Compute per-row heights based on how many lines each cell needs. - -- Also account for the raw (unrendered) buffer line wrapping: if the source - -- text is longer than the rendered text (e.g. a long concealed URL), the - -- buffer line may wrap onto more screen lines than the rendered content - -- requires. We must cover all of those screen lines with overlay marks, - -- so the effective height is max(rendered_lines, raw_screen_lines). + -- Compute per-row heights based on how many rendered lines each cell needs. + -- Long delimiter cells can also force wrapping even when row contents fit. local row_heights = {} ---@type integer[] local needs_wrap = false + for i, width in ipairs(max_content) do + needs_wrap = needs_wrap or width > col_widths[i] + end for r, row in ipairs(self.data.rows) do local max_lines = 1 for i, cell in ipairs(row.cells) do local w = col_widths[i] - if w > 0 and cell.width > w then - local lines = math.ceil(cell.width / w) + local width = content_width(cell) + if w > 0 and width > w then + local lines = math.ceil(width / w) if lines > max_lines then max_lines = lines end needs_wrap = true end end - -- Raw buffer line screen-wrap: ceil(display_width_of_source / win_width) - local _, line = row.node:line('first', 0) - line = line or '' - local raw_screen_lines = - math.max(1, math.ceil(str.width(line) / win_width)) - if raw_screen_lines > max_lines then - max_lines = raw_screen_lines - needs_wrap = true - end row_heights[r] = max_lines end @@ -293,6 +288,7 @@ function Render:parse_cols(node) end cols[#cols + 1] = { width = width, + delimiter_width = math.max(width, self.config.min_width), alignment = Render.alignment(cell), } end From 47c4d522fee5736d29a6c50f54e6873d38180d1f Mon Sep 17 00:00:00 2001 From: Oula Kuuva Date: Fri, 5 Jun 2026 22:04:17 +0300 Subject: [PATCH 10/16] refactor(table): extract table line builders --- lua/render-markdown/render/markdown/table.lua | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index c27adaba..841f65a8 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -480,6 +480,17 @@ end ---@private function Render:delimiter() local delim = self.data.delim + local line = self:delimiter_line(self:delimiter_text()) + line:pad(str.width(delim.text) - line:width()) + self.marks:over(self.config, 'table_border', delim, { + virt_text = line:get(), + virt_text_pos = 'overlay', + }) +end + +---@private +---@return string +function Render:delimiter_text() local border = self.config.border local indicator = self.config.alignment_indicator @@ -502,16 +513,34 @@ function Render:delimiter() return indicator .. icon:rep(col.width - 2) .. indicator end end) - local delimiter = border[4] .. table.concat(parts, border[5]) .. border[6] + return border[4] .. table.concat(parts, border[5]) .. border[6] +end - local line = self:line() - line:pad(str.spaces('start', delim.text)) - line:text(delimiter, self.config.head) - line:pad(str.width(delim.text) - line:width()) - self.marks:over(self.config, 'table_border', delim, { - virt_text = line:get(), - virt_text_pos = 'overlay', - }) +---@private +---@param delimiter string +---@return render.md.Line +function Render:delimiter_line(delimiter) + return self:line() + :pad(str.spaces('start', self.data.delim.text)) + :text(delimiter, self.config.head) +end + +---@private +---@param above boolean +---@return render.md.Line +function Render:border_line(above) + local border = self.config.border + local chars = above and { border[1], border[2], border[3] } + or { border[7], border[8], border[9] } + local icon = border[11] + local parts = iter.list.map(self.data.cols, function(col) + return icon:rep(col.width) + end) + local text = chars[1] .. table.concat(parts, chars[2]) .. chars[3] + local highlight = above and self.config.head or self.config.row + local first = self.data.rows[1].node + local spaces = math.max(str.spaces('start', first.text), first.start_col) + return self:line():pad(spaces):text(text, highlight) end ---@private From d42710a3160d0002fd39e7db7907cdb4bf41901a Mon Sep 17 00:00:00 2001 From: Oula Kuuva Date: Fri, 5 Jun 2026 22:02:18 +0300 Subject: [PATCH 11/16] fix(table): place wrapped tables as visual lines --- lua/render-markdown/render/markdown/table.lua | 159 +++++++++++------- tests/table_spec.lua | 36 ++++ 2 files changed, 130 insertions(+), 65 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index 841f65a8..95dd2b96 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -462,15 +462,14 @@ end ---@protected function Render:run() - self:delimiter() if self.layout.wrap then - for r, row in ipairs(self.data.rows) do - self:row_wrapped(row, r) - end - else - for _, row in ipairs(self.data.rows) do - self:row(row) - end + self:wrapped() + return + end + + self:delimiter() + for _, row in ipairs(self.data.rows) do + self:row(row) end if self.config.border_enabled and self.data.layout.valid then self:border() @@ -603,7 +602,8 @@ end ---@private ---@param row render.md.table.Row ---@param row_index integer -function Render:row_wrapped(row, row_index) +---@return render.md.Line[] +function Render:row_wrapped_lines(row, row_index) local height = self.layout.row_heights[row_index] local header = row.node.type == 'pipe_table_header' local highlight = header and self.config.head or self.config.row @@ -619,7 +619,8 @@ function Render:row_wrapped(row, row_index) end local filler = self.config.filler - local function build_line(visual_line) + local result = {} ---@type render.md.Line[] + for visual_line = 0, height - 1 do local line = self:line() line:pad(spaces, filler) for i, _ in ipairs(self.data.cols) do @@ -637,72 +638,100 @@ function Render:row_wrapped(row, row_index) line:pad(padding, filler) end line:text(border_icon, highlight) - return line + result[#result + 1] = line end + return result +end - local _, buf_line = row.node:line('first', 0) - buf_line = buf_line or '' - local win_width = env.win.width(self.context.win) - local buf_screen_lines = - math.max(1, math.ceil(str.width(buf_line) / win_width)) - - -- Line 0: conceal the source line then overlay the rendered row on top. - if #buf_line > 0 then - self.marks:add(self.config, 'table_border', row.node.start_row, 0, { - end_row = row.node.start_row, - end_col = #buf_line, - conceal = '', - }) +---@private +function Render:wrapped() + local visual = {} ---@type render.md.Line[] + for r, row in ipairs(self.data.rows) do + vim.list_extend(visual, self:row_wrapped_lines(row, r)) + if r == 1 then + visual[#visual + 1] = self:delimiter_line(self:delimiter_text()) + end end - local first_line = build_line(0) - self.marks:add(self.config, 'table_border', row.node.start_row, 0, { - virt_text = first_line:get(), - virt_text_pos = 'overlay', - hl_mode = 'combine', - }) + if self.config.border_enabled then + if #self.data.rows > 1 then + visual[#visual + 1] = self:border_line(false) + end - -- Lines 1..height-1: overlay buffer wrap continuations, then virt_lines. - local virt_lines = {} ---@type render.md.mark.Line[] - for vl = 1, height - 1 do - if vl < buf_screen_lines then - local byte_col = vim.fn.byteidx(buf_line, vl * win_width) + local first = self.data.rows[1].node + local line = self:border_line(true) + local row, target = first:line('above', 1) + if + target + and str.width(target) == 0 + and self.context.used:take(row) + then + self.marks:add(self.config, 'table_border', row, 0, { + virt_text = line:get(), + virt_text_pos = 'overlay', + }) + else + self.marks:add(self.config, 'virtual_lines', first.start_row, 0, { + virt_lines = { self:indent():line(true):extend(line):get() }, + virt_lines_above = true, + }) + end + end + + local nodes = { self.data.rows[1].node, self.data.delim } ---@type render.md.Node[] + for i = 2, #self.data.rows do + nodes[#nodes + 1] = self.data.rows[i].node + end + + local win_width = env.win.width(self.context.win) + local slots = {} ---@type { row: integer, col: integer }[] + for _, node in ipairs(nodes) do + local _, buf_line = node:line('first', 0) + buf_line = buf_line or '' + if #buf_line > 0 then + self.marks:add(self.config, 'table_border', node.start_row, 0, { + end_row = node.start_row, + end_col = #buf_line, + conceal = '', + }) + end + local screen_lines = + math.max(math.ceil(str.width(buf_line) / win_width), 1) + for line = 0, screen_lines - 1 do + local byte_col = line == 0 and 0 + or vim.fn.byteidx(buf_line, line * win_width) if byte_col < 0 then byte_col = #buf_line end - if #virt_lines > 0 then - self.marks:add( - self.config, - 'virtual_lines', - row.node.start_row, - 0, - { - virt_lines = virt_lines, - virt_lines_above = false, - } - ) - virt_lines = {} - end - self.marks:add( - self.config, - 'table_border', - row.node.start_row, - byte_col, - { - virt_text = build_line(vl):get(), - virt_text_pos = 'overlay', - hl_mode = 'combine', - } - ) + slots[#slots + 1] = { row = node.start_row, col = byte_col } + end + end + + local virt_lines = {} ---@type render.md.mark.Line[] + for i, line in ipairs(visual) do + local slot = slots[i] + if slot then + self.marks:add(self.config, 'table_border', slot.row, slot.col, { + virt_text = line:get(), + virt_text_pos = 'overlay', + hl_mode = 'combine', + }) else - local vline = self:indent():line(true):extend(build_line(vl)) - virt_lines[#virt_lines + 1] = vline:get() + virt_lines[#virt_lines + 1] = + self:indent():line(true):extend(line):get() end end if #virt_lines > 0 then - self.marks:add(self.config, 'virtual_lines', row.node.start_row, 0, { - virt_lines = virt_lines, - virt_lines_above = false, - }) + local last = self.data.rows[#self.data.rows].node + self.marks:add( + self.config, + 'virtual_lines', + last.start_row, + last.end_col, + { + virt_lines = virt_lines, + virt_lines_above = false, + } + ) end end diff --git a/tests/table_spec.lua b/tests/table_spec.lua index 16eb446d..e981b893 100644 --- a/tests/table_spec.lua +++ b/tests/table_spec.lua @@ -301,4 +301,40 @@ describe('table', function() '└──────┴────────────────────────────────────────┴──────────┘', }) end) + + it('wrapped long delimiter', function() + util.setup.text({ + '', + '| ID | Title | Severity | Status |', + '|-------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------|', + "| [T-1] | Here's a long text that definitely causes wrapping. My hands are typing words. If you're reading this then my hands continued typing words to make long enough a line for testing. Also you possess the highly coveted skill of reading. Good for you. Sorry, didn't mean to sound so sarcastic there. Honestly I'm thrilled for you. I have just trouble showing it. | High | Open |", + }, { + pipe_table = { max_table_width = 60 }, + win_options = { wrap = { default = false, rendered = true } }, + }) + + util.assert_screen({ + '┌─────────┬────────────────────────┬────────────┬──────────┐', + '│ ID │ Title │ Severity │ Status │', + '├─────────┼────────────────────────┼────────────┼──────────┤', + "│ T-1 │ Here's a long text tha │ High │ Open │", + '│ │ t definitely causes wr │ │ │', + '│ │ apping. My hands are t │ │ │', + "│ │ yping words. If you're │ │ │", + '│ │ reading this then my │ │ │', + '│ │ hands continued typing │ │ │', + '│ │ words to make long en │ │ │', + '│ │ ough a line for testin │ │ │', + '│ │ g. Also you possess th │ │ │', + '│ │ e highly coveted skill │ │ │', + '│ │ of reading. Good for │ │ │', + "│ │ you. Sorry, didn't mea │ │ │", + '│ │ n to sound so sarcasti │ │ │', + "│ │ c there. Honestly I'm │ │ │", + '│ │ thrilled for you. I ha │ │ │', + '│ │ ve just trouble showin │ │ │', + '│ │ g it. │ │ │', + '└─────────┴────────────────────────┴────────────┴──────────┘', + }) + end) end) From 7739ec585d025d846d82c08c84596de470e930d8 Mon Sep 17 00:00:00 2001 From: Oula Kuuva Date: Fri, 5 Jun 2026 22:15:04 +0300 Subject: [PATCH 12/16] fix(table): indented wrapped table rendering --- lua/render-markdown/render/markdown/table.lua | 20 ++++++--- tests/table_spec.lua | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index 95dd2b96..cffa4257 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -166,17 +166,18 @@ function Render:compute_layout() end local win_width = env.win.width(self.context.win) + local remaining_width = math.max(win_width - self:indent_width(), 1) local mtw = self.config.max_table_width local available if mtw < 0 then -- Negative: characters from right edge - available = win_width + mtw + available = remaining_width + mtw elseif mtw <= 1 then -- Fraction of window width - available = math.floor(win_width * mtw) + available = math.floor(remaining_width * mtw) else - -- Absolute character width - available = math.floor(mtw) + -- Absolute character width, capped to fit after indentation + available = math.min(math.floor(mtw), remaining_width) end local num_cols = #self.data.cols local padding = self.config.padding @@ -267,6 +268,13 @@ function Render:compute_layout() return { wrap = true, col_widths = col_widths, row_heights = row_heights } end +---@private +---@return integer +function Render:indent_width() + local first = self.data.rows[1].node + return math.max(str.spaces('start', first.text), first.start_col) +end + ---@private ---@param node render.md.Node ---@return render.md.table.Col[]? @@ -537,9 +545,7 @@ function Render:border_line(above) end) local text = chars[1] .. table.concat(parts, chars[2]) .. chars[3] local highlight = above and self.config.head or self.config.row - local first = self.data.rows[1].node - local spaces = math.max(str.spaces('start', first.text), first.start_col) - return self:line():pad(spaces):text(text, highlight) + return self:line():pad(self:indent_width()):text(text, highlight) end ---@private diff --git a/tests/table_spec.lua b/tests/table_spec.lua index e981b893..f6d2e801 100644 --- a/tests/table_spec.lua +++ b/tests/table_spec.lua @@ -3,6 +3,18 @@ local util = require('tests.util') describe('table', function() + ---@param columns integer + ---@param callback function + local function with_columns(columns, callback) + local previous = vim.o.columns + vim.o.columns = columns + local ok, err = pcall(callback) + vim.o.columns = previous + if not ok then + error(err, 0) + end + end + local lines = { '', '| Heading 1 | `Heading 2` |', @@ -302,6 +314,37 @@ describe('table', function() }) end) + it('wrapped indented table', function() + with_columns(60, function() + util.setup.text({ + '', + ' | Approach | Allocations | Performance |', + ' |----------|-------------|-------------|', + ' | `format!()` in loop | N | Slow |', + ' | `write!()` to reused buffer | 1 | Fast |', + ' | `push_str()` + `push()` | 1 | Fastest |', + ' | Pre-sized `String::with_capacity()` | 1 (no realloc) | Fast |', + }, { + pipe_table = { max_table_width = -2 }, + win_options = { wrap = { default = false, rendered = true } }, + }) + + util.assert_screen({ + ' ┌────────────────────┬────────────────┬───────────────┐', + ' │ Approach │ Allocations │ Performance │', + ' ├────────────────────┼────────────────┼───────────────┤', + ' │ format!() in loop │ N │ Slow │', + ' │ write!() to reused │ 1 │ Fast │', + ' │ buffer │ │ │', + ' │ push_str() + push( │ 1 │ Fastest │', + ' │ ) │ │ │', + ' │ Pre-sized String:: │ 1 (no realloc) │ Fast │', + ' │ with_capacity() │ │ │', + ' └────────────────────┴────────────────┴───────────────┘', + }) + end) + end) + it('wrapped long delimiter', function() util.setup.text({ '', From 1af7c1467a5ad83cea7abf16c39cba5ccb617a60 Mon Sep 17 00:00:00 2001 From: Oula Kuuva Date: Fri, 5 Jun 2026 23:44:03 +0300 Subject: [PATCH 13/16] fix(table): wrap at word boundary --- lua/render-markdown/render/markdown/table.lua | 104 +++++++++++++++--- tests/table_spec.lua | 55 ++++----- 2 files changed, 117 insertions(+), 42 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index cffa4257..448b91b9 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -249,14 +249,11 @@ function Render:compute_layout() local max_lines = 1 for i, cell in ipairs(row.cells) do local w = col_widths[i] - local width = content_width(cell) - if w > 0 and width > w then - local lines = math.ceil(width / w) - if lines > max_lines then - max_lines = lines - end - needs_wrap = true + local lines = #self:wrap_line(self:cell_line(cell.node), w) + if lines > max_lines then + max_lines = lines end + needs_wrap = needs_wrap or lines > 1 end row_heights[r] = max_lines end @@ -320,6 +317,15 @@ function Render.alignment(node) end end +---@private +---@param node render.md.Node +---@return render.md.Line +function Render:cell_line(node) + local line = Line.new(self.config.filler) + vim.list_extend(line:get(), self:cell_segments(node)) + return line +end + ---Compute display segments for a cell: raw text − concealed + injected, ---with treesitter highlight groups preserved. ---@private @@ -605,6 +611,76 @@ function Render:row(row) end end +---@private +---@param line render.md.Line +---@param width integer +---@return render.md.Line[] +function Render:wrap_line(line, width) + if width <= 0 then + return { Line.new(self.config.filler) } + end + + local total = line:width() + if total == 0 then + return { Line.new(self.config.filler) } + end + + local spaces = {} ---@type table + local column = 1 + for _, segment in ipairs(line:get()) do + local text = segment[1] + local bytes = vim.str_utf_pos(text) + for index, start_byte in ipairs(bytes) do + local end_byte = index < #bytes and bytes[index + 1] - 1 or #text + local char = text:sub(start_byte, end_byte) + local width = str.width(char) + if char:match('^%s$') then + spaces[column] = true + end + column = column + width + end + end + + ---@param column integer + ---@return boolean + local function is_space(column) + return spaces[column] == true + end + + local result = {} ---@type render.md.Line[] + local start = 1 + while start <= total do + while start <= total and is_space(start) do + start = start + 1 + end + if start > total then + break + end + + local limit = math.min(start + width - 1, total) + local chunk_end = limit + local next_start = limit + 1 + if limit < total then + for column = limit, start, -1 do + if is_space(column) then + chunk_end = column - 1 + next_start = column + 1 + break + end + end + end + if chunk_end < start then + chunk_end = limit + next_start = limit + 1 + end + + result[#result + 1] = line:sub(start, chunk_end) + start = next_start + end + + return #result > 0 and result or { Line.new(self.config.filler) } +end + ---@private ---@param row render.md.table.Row ---@param row_index integer @@ -618,10 +694,11 @@ function Render:row_wrapped_lines(row, row_index) local spaces = math.max(str.spaces('start', row.node.text), row.node.start_col) - -- Pre-compute display segments for each cell in this row - local cell_segs = {} ---@type render.md.mark.Line[] + -- Pre-compute wrapped display lines for each cell in this row + local cell_lines = {} ---@type render.md.Line[][] for i, cell in ipairs(row.cells) do - cell_segs[i] = self:cell_segments(cell.node) + cell_lines[i] = + self:wrap_line(self:cell_line(cell.node), self.layout.col_widths[i]) end local filler = self.config.filler @@ -633,12 +710,7 @@ function Render:row_wrapped_lines(row, row_index) local col_width = self.layout.col_widths[i] line:text(border_icon, highlight) line:pad(padding, filler) - local cell_line = Line.new(filler) - vim.list_extend(cell_line:get(), cell_segs[i] or {}) - local chunk = cell_line:sub( - visual_line * col_width + 1, - (visual_line + 1) * col_width - ) + local chunk = cell_lines[i][visual_line + 1] or Line.new(filler) line:extend(chunk) line:pad(col_width - chunk:width(), filler) line:pad(padding, filler) diff --git a/tests/table_spec.lua b/tests/table_spec.lua index f6d2e801..8d75eee3 100644 --- a/tests/table_spec.lua +++ b/tests/table_spec.lua @@ -307,9 +307,9 @@ describe('table', function() '┌──────┬────────────────────────────────────────┬──────────┐', '│ ID │ Title │ Status │', '├──────┼────────────────────────────────────────┼──────────┤', - '│ 1 │ This sentence is long enough to wrap a │ Open │', - '│ │ cross several rendered table lines wit │ │', - '│ │ hout needing a trailing blank line. │ │', + '│ 1 │ This sentence is long enough to wrap │ Open │', + '│ │ across several rendered table lines │ │', + '│ │ without needing a trailing blank line. │ │', '└──────┴────────────────────────────────────────┴──────────┘', }) end) @@ -334,12 +334,13 @@ describe('table', function() ' │ Approach │ Allocations │ Performance │', ' ├────────────────────┼────────────────┼───────────────┤', ' │ format!() in loop │ N │ Slow │', - ' │ write!() to reused │ 1 │ Fast │', - ' │ buffer │ │ │', - ' │ push_str() + push( │ 1 │ Fastest │', - ' │ ) │ │ │', - ' │ Pre-sized String:: │ 1 (no realloc) │ Fast │', - ' │ with_capacity() │ │ │', + ' │ write!() to │ 1 │ Fast │', + ' │ reused buffer │ │ │', + ' │ push_str() + │ 1 │ Fastest │', + ' │ push() │ │ │', + ' │ Pre-sized │ 1 (no realloc) │ Fast │', + ' │ String::with_capac │ │ │', + ' │ ity() │ │ │', ' └────────────────────┴────────────────┴───────────────┘', }) end) @@ -360,23 +361,25 @@ describe('table', function() '┌─────────┬────────────────────────┬────────────┬──────────┐', '│ ID │ Title │ Severity │ Status │', '├─────────┼────────────────────────┼────────────┼──────────┤', - "│ T-1 │ Here's a long text tha │ High │ Open │", - '│ │ t definitely causes wr │ │ │', - '│ │ apping. My hands are t │ │ │', - "│ │ yping words. If you're │ │ │", - '│ │ reading this then my │ │ │', - '│ │ hands continued typing │ │ │', - '│ │ words to make long en │ │ │', - '│ │ ough a line for testin │ │ │', - '│ │ g. Also you possess th │ │ │', - '│ │ e highly coveted skill │ │ │', - '│ │ of reading. Good for │ │ │', - "│ │ you. Sorry, didn't mea │ │ │", - '│ │ n to sound so sarcasti │ │ │', - "│ │ c there. Honestly I'm │ │ │", - '│ │ thrilled for you. I ha │ │ │', - '│ │ ve just trouble showin │ │ │', - '│ │ g it. │ │ │', + "│ T-1 │ Here's a long text │ High │ Open │", + '│ │ that definitely │ │ │', + '│ │ causes wrapping. My │ │ │', + '│ │ hands are typing │ │ │', + "│ │ words. If you're │ │ │", + '│ │ reading this then my │ │ │', + '│ │ hands continued │ │ │', + '│ │ typing words to make │ │ │', + '│ │ long enough a line │ │ │', + '│ │ for testing. Also you │ │ │', + '│ │ possess the highly │ │ │', + '│ │ coveted skill of │ │ │', + '│ │ reading. Good for │ │ │', + "│ │ you. Sorry, didn't │ │ │", + '│ │ mean to sound so │ │ │', + '│ │ sarcastic there. │ │ │', + "│ │ Honestly I'm thrilled │ │ │", + '│ │ for you. I have just │ │ │', + '│ │ trouble showing it. │ │ │', '└─────────┴────────────────────────┴────────────┴──────────┘', }) end) From 01f4718f17de10144d58b272128798631c714158 Mon Sep 17 00:00:00 2001 From: Oula Kuuva Date: Sat, 6 Jun 2026 01:47:44 +0300 Subject: [PATCH 14/16] fix(table): class name collision `render.md.table.Layout` was already introduced to `main`. Renamed the new `Layout` into `WrapLayout`. --- lua/render-markdown/render/markdown/table.lua | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index 448b91b9..e7d9001e 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -48,15 +48,15 @@ local Alignment = { ---@field pipes render.md.Node[] ---@field cells render.md.Node[] ----@class render.md.table.Layout ----@field wrap boolean +---@class render.md.table.WrapLayout +---@field enabled boolean ---@field col_widths integer[] ---@field row_heights integer[] ---@class render.md.render.Table: render.md.Render ---@field private config render.md.table.Config ---@field private data render.md.table.Data ----@field private layout render.md.table.Layout +---@field private wrap_layout render.md.table.WrapLayout local Render = setmetatable({}, Base) Render.__index = Render @@ -132,12 +132,12 @@ function Render:setup() end self.data = { layout = layout, delim = delim, cols = cols, rows = rows } - self.layout = self:compute_layout() + self.wrap_layout = self:compute_wrap_layout() -- When wrapping, update col widths so delimiter/border rendering -- uses the capped widths (padding is included in col width). - if self.layout.wrap then - for i, w in ipairs(self.layout.col_widths) do + if self.wrap_layout.enabled then + for i, w in ipairs(self.wrap_layout.col_widths) do self.data.cols[i].width = w + 2 * self.config.padding end end @@ -146,9 +146,9 @@ function Render:setup() end ---@private ----@return render.md.table.Layout -function Render:compute_layout() - local no_wrap = { wrap = false, col_widths = {}, row_heights = {} } +---@return render.md.table.WrapLayout +function Render:compute_wrap_layout() + local no_wrap = { enabled = false, col_widths = {}, row_heights = {} } -- Feature disabled when max_table_width is 0 (unset) if self.config.max_table_width == 0 then @@ -262,7 +262,7 @@ function Render:compute_layout() return no_wrap end - return { wrap = true, col_widths = col_widths, row_heights = row_heights } + return { enabled = true, col_widths = col_widths, row_heights = row_heights } end ---@private @@ -476,7 +476,7 @@ end ---@protected function Render:run() - if self.layout.wrap then + if self.wrap_layout.enabled then self:wrapped() return end @@ -686,7 +686,7 @@ end ---@param row_index integer ---@return render.md.Line[] function Render:row_wrapped_lines(row, row_index) - local height = self.layout.row_heights[row_index] + local height = self.wrap_layout.row_heights[row_index] local header = row.node.type == 'pipe_table_header' local highlight = header and self.config.head or self.config.row local border_icon = self.config.border[10] @@ -697,8 +697,10 @@ function Render:row_wrapped_lines(row, row_index) -- Pre-compute wrapped display lines for each cell in this row local cell_lines = {} ---@type render.md.Line[][] for i, cell in ipairs(row.cells) do - cell_lines[i] = - self:wrap_line(self:cell_line(cell.node), self.layout.col_widths[i]) + cell_lines[i] = self:wrap_line( + self:cell_line(cell.node), + self.wrap_layout.col_widths[i] + ) end local filler = self.config.filler @@ -707,7 +709,7 @@ function Render:row_wrapped_lines(row, row_index) local line = self:line() line:pad(spaces, filler) for i, _ in ipairs(self.data.cols) do - local col_width = self.layout.col_widths[i] + local col_width = self.wrap_layout.col_widths[i] line:text(border_icon, highlight) line:pad(padding, filler) local chunk = cell_lines[i][visual_line + 1] or Line.new(filler) @@ -893,7 +895,7 @@ function Render:border() }) else local col = 0 - if not above and self.layout.wrap then + if not above and self.wrap_layout.enabled then -- Place after wrapped row virtual lines at column 0. col = node.end_col end From 24a7b97d0de39a6df510d6606b4be452ba1661f8 Mon Sep 17 00:00:00 2001 From: Oula Kuuva Date: Sat, 6 Jun 2026 17:43:19 +0300 Subject: [PATCH 15/16] fix(table): align wrapped continuations Narrow windows with linebreak, showbreak, or breakindent move continuation screen-line starts. Wrapped table overlays must use the same slots or rows render under the continuation prefix. --- lua/render-markdown/render/markdown/table.lua | 80 ++++++++++++++++--- tests/table_spec.lua | 31 +++++++ 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index e7d9001e..c12a1097 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -723,6 +723,74 @@ function Render:row_wrapped_lines(row, row_index) return result end +---@private +---@param text string +---@param win_width integer +---@return integer[] +function Render:wrapped_slots(text, win_width) + if #text == 0 then + return { 0 } + end + + local linebreak = env.win.get(self.context.win, 'linebreak') == true + local breakat = vim.api.nvim_get_option_value('breakat', {}) + local showbreak = env.win.get(self.context.win, 'showbreak') + local breakindent = env.win.get(self.context.win, 'breakindent') == true + local indent = breakindent and str.spaces('start', text) or 0 + local continuation_width = + math.max(win_width - str.width(tostring(showbreak)) - indent, 1) + + local chars = {} ---@type { col: integer, text: string, width: integer }[] + local bytes = vim.str_utf_pos(text) + for index, start_byte in ipairs(bytes) do + local end_byte = index < #bytes and bytes[index + 1] - 1 or #text + local char = text:sub(start_byte, end_byte) + chars[#chars + 1] = { + col = start_byte - 1, + text = char, + width = str.width(char), + } + end + + local slots = {} ---@type integer[] + local index = 1 + local first = true + while index <= #chars do + slots[#slots + 1] = chars[index].col + local width = first and win_width or continuation_width + local used = 0 + local next_index = index + local break_index = nil ---@type integer? + while next_index <= #chars do + local char = chars[next_index] + if used > 0 and used + char.width > width then + break + end + used = used + char.width + if linebreak and breakat:find(char.text, 1, true) then + break_index = next_index + end + next_index = next_index + 1 + if used >= width then + break + end + end + if next_index > #chars then + break + elseif linebreak and break_index and break_index >= index then + index = break_index + 1 + while index <= #chars and chars[index].text:match('^%s$') do + index = index + 1 + end + else + index = next_index + end + first = false + end + + return slots +end + ---@private function Render:wrapped() local visual = {} ---@type render.md.Line[] @@ -774,15 +842,8 @@ function Render:wrapped() conceal = '', }) end - local screen_lines = - math.max(math.ceil(str.width(buf_line) / win_width), 1) - for line = 0, screen_lines - 1 do - local byte_col = line == 0 and 0 - or vim.fn.byteidx(buf_line, line * win_width) - if byte_col < 0 then - byte_col = #buf_line - end - slots[#slots + 1] = { row = node.start_row, col = byte_col } + for _, col in ipairs(self:wrapped_slots(buf_line, win_width)) do + slots[#slots + 1] = { row = node.start_row, col = col } end end @@ -793,6 +854,7 @@ function Render:wrapped() self.marks:add(self.config, 'table_border', slot.row, slot.col, { virt_text = line:get(), virt_text_pos = 'overlay', + virt_text_win_col = 0, hl_mode = 'combine', }) else diff --git a/tests/table_spec.lua b/tests/table_spec.lua index 8d75eee3..91548907 100644 --- a/tests/table_spec.lua +++ b/tests/table_spec.lua @@ -346,6 +346,37 @@ describe('table', function() end) end) + it('wrapped showbreak continuation', function() + with_columns(36, function() + util.setup.text({ + '', + '| ID | Title |', + '| -- | ----- |', + '| 1 | alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau |', + }, { + pipe_table = { max_table_width = 30 }, + win_options = { + wrap = { default = false, rendered = true }, + linebreak = { default = false, rendered = true }, + showbreak = { default = '', rendered = '>>>>' }, + }, + }) + + util.assert_screen({ + '┌──────┬─────────────────────┐', + '│ ID │ Title │', + '├──────┼─────────────────────┤', + '│ 1 │ alpha beta gamma │', + '│ │ delta epsilon zeta │', + '│ │ eta theta iota │', + '│ │ kappa lambda mu nu │', + '│ │ xi omicron pi rho │', + '│ │ sigma tau │', + '└──────┴─────────────────────┘', + }) + end) + end) + it('wrapped long delimiter', function() util.setup.text({ '', From 969b742657ff7a43a21ba52ab0f033b3161682d5 Mon Sep 17 00:00:00 2001 From: Oula Kuuva Date: Sat, 6 Jun 2026 10:42:36 +0300 Subject: [PATCH 16/16] fix(table): align cells in wrapped tables Both the wrapped and regular cells. --- lua/render-markdown/render/markdown/table.lua | 19 ++++++-- tests/table_spec.lua | 45 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index c12a1097..7d25740b 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -712,9 +712,22 @@ function Render:row_wrapped_lines(row, row_index) local col_width = self.wrap_layout.col_widths[i] line:text(border_icon, highlight) line:pad(padding, filler) - local chunk = cell_lines[i][visual_line + 1] or Line.new(filler) - line:extend(chunk) - line:pad(col_width - chunk:width(), filler) + local chunks = cell_lines[i] + local chunk = chunks[visual_line + 1] or Line.new(filler) + local fill = col_width - chunk:width() + local alignment = self.data.cols[i].alignment + if alignment == Alignment.center then + local left = math.floor(fill / 2) + line:pad(left, filler) + line:extend(chunk) + line:pad(fill - left, filler) + elseif alignment == Alignment.right then + line:pad(fill, filler) + line:extend(chunk) + else + line:extend(chunk) + line:pad(fill, filler) + end line:pad(padding, filler) end line:text(border_icon, highlight) diff --git a/tests/table_spec.lua b/tests/table_spec.lua index 91548907..44a1c82f 100644 --- a/tests/table_spec.lua +++ b/tests/table_spec.lua @@ -377,6 +377,51 @@ describe('table', function() end) end) + it('wrapped aligns unwrapped cells', function() + util.setup.text({ + '', + '|Left|Right|Center|Long|', + '|:---|----:|:----:|----|', + '|a|2|mid|one two three four five six seven|', + }, { + pipe_table = { max_table_width = 40 }, + win_options = { wrap = { default = false, rendered = true } }, + }) + + util.assert_screen({ + '┌──────┬───────┬────────┬──────────────┐', + '│ Left │ Right │ Center │ Long │', + '├━─────┼──────━┼━──────━┼──────────────┤', + '│ a │ 2 │ mid │ one two │', + '│ │ │ │ three four │', + '│ │ │ │ five six │', + '│ │ │ │ seven │', + '└──────┴───────┴────────┴──────────────┘', + }) + end) + + it('wrapped aligns wrapped cells', function() + util.setup.text({ + '', + '| L | R | C |', + '|:--|--:|:-:|', + '| a | one two three four five six | one two three four five six |', + }, { + pipe_table = { max_table_width = 40 }, + win_options = { wrap = { default = false, rendered = true } }, + }) + + util.assert_screen({ + '┌─────┬───────────────┬───────────────┐', + '│ L │ R │ C │', + '├━────┼──────────────━┼━─────────────━┤', + '│ a │ one two │ one two │', + '│ │ three four │ three four │', + '│ │ five six │ five six │', + '└─────┴───────────────┴───────────────┘', + }) + end) + it('wrapped long delimiter', function() util.setup.text({ '',