From fba23bd1045c241fd9c59657786837fdce558627 Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 3 Mar 2026 14:48:29 -0800 Subject: [PATCH 1/8] feat(table): add cell wrapping using virt lines --- lua/render-markdown/core/manager.lua | 24 ++ lua/render-markdown/lib/marks.lua | 1 + lua/render-markdown/render/markdown/table.lua | 330 +++++++++++++++++- lua/render-markdown/request/offset.lua | 17 + lua/render-markdown/settings.lua | 13 + 5 files changed, 383 insertions(+), 2 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/lib/marks.lua b/lua/render-markdown/lib/marks.lua index f399e50c..90514cdb 100644 --- a/lua/render-markdown/lib/marks.lua +++ b/lua/render-markdown/lib/marks.lua @@ -139,6 +139,7 @@ function Marks:run_update(mark) self.context.offset:add(row, { col = start_col, width = str.line_width(opts.virt_text), + virt_text = opts.virt_text or {}, }) end end diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index 033c8e52..1c7dbbce 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') @@ -38,9 +40,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 @@ -110,10 +118,157 @@ function Render:setup() end self.data = { delim = delim, cols = cols, rows = rows } + self.layout = self:compute_layout() + + -- When wrapping, update delim col widths so delimiter/border rendering + -- uses the capped widths (padding is included in delim col width). + if self.layout.wrap then + for i, w in ipairs(self.layout.col_widths) do + self.data.delim.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.delim.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.delim.cols[i].width - 2 * padding, + self.config.min_width + ) + end + for _, row in ipairs(self.data.rows) do + for i, col in ipairs(row.cols) do + max_content[i] = math.max(max_content[i], col.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, col in ipairs(row.cols) do + local w = col_widths[i] + if w > 0 and col.width > w then + local lines = math.ceil(col.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[]? @@ -158,6 +313,81 @@ 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.offset: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.virt_text) 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 @@ -220,8 +450,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 then self:border() @@ -322,6 +558,96 @@ 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, col in ipairs(row.cols) do + cell_segs[i] = self:cell_segments(col.row, col.start_col, col.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.delim.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/offset.lua b/lua/render-markdown/request/offset.lua index 5ca43b07..09b1b1f3 100644 --- a/lua/render-markdown/request/offset.lua +++ b/lua/render-markdown/request/offset.lua @@ -1,6 +1,7 @@ ---@class render.md.request.offset.Value ---@field col integer ---@field width integer +---@field virt_text render.md.mark.Line original virtual text segments ---@class render.md.request.Offset ---@field private values table @@ -40,4 +41,20 @@ function Offset:get(body) return result end +---Return injections within a column range on a row, sorted by col. +---@param row integer +---@param start_col integer +---@param end_col integer +---@return render.md.request.offset.Value[] +function Offset:range(row, start_col, end_col) + local result = {} ---@type render.md.request.offset.Value[] + for _, value in ipairs(self.values[row] or {}) do + if value.col >= start_col and value.col < end_col then + result[#result + 1] = value + end + end + table.sort(result, function(a, b) return a.col < b.col end) + return result +end + return Offset diff --git a/lua/render-markdown/settings.lua b/lua/render-markdown/settings.lua index 776c7fc0..13a26d1e 100644 --- a/lua/render-markdown/settings.lua +++ b/lua/render-markdown/settings.lua @@ -1611,6 +1611,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 @@ -1671,6 +1672,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 @@ -1706,6 +1718,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 3f57a779ba4ff299b12e703e8720dee4a7b2d6b7 Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 3 Mar 2026 16:48:26 -0800 Subject: [PATCH 2/8] 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 1c7dbbce..d741538b 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -191,11 +191,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 cffa731a730fcf31811e893e33c3b36db2d2db25 Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 10 Mar 2026 15:23:03 -0700 Subject: [PATCH 3/8] 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 ea47eca73aacdfd8e97c4f7bf8502c7df41b7f24 Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 10 Mar 2026 15:23:28 -0700 Subject: [PATCH 4/8] 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 d741538b..6fc18fff 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -186,11 +186,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 2c808ae38fc0767dba74f647c7869390cb342c48 Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 10 Mar 2026 15:25:56 -0700 Subject: [PATCH 5/8] fix(table): remove unnecessary line height check --- lua/render-markdown/render/markdown/table.lua | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index 6fc18fff..05237512 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -606,36 +606,34 @@ 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 - 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', + 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, }) - 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', row.node.start_row, 0, { - virt_lines = virt_lines, - virt_lines_above = false, + 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 ---Use low priority to include pipe marks From d6b04e3140aa63696e73c2e0d5c9dafceb9e3bc8 Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 10 Mar 2026 16:07:26 -0700 Subject: [PATCH 6/8] fix(table): resolve variable mismatch rebase conflict issues --- lua/render-markdown/render/markdown/table.lua | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index 05237512..7ee700ca 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -124,7 +124,7 @@ function Render:setup() -- uses the capped widths (padding is included in delim col width). if self.layout.wrap then for i, w in ipairs(self.layout.col_widths) do - self.data.delim.cols[i].width = w + 2 * self.config.padding + self.data.cols[i].width = w + 2 * self.config.padding end end @@ -164,7 +164,7 @@ function Render:compute_layout() -- Absolute character width available = math.floor(mtw) end - local num_cols = #self.data.delim.cols + 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) @@ -176,12 +176,12 @@ function Render:compute_layout() local max_content = {} ---@type integer[] for i = 1, num_cols do max_content[i] = math.max( - self.data.delim.cols[i].width - 2 * padding, + self.data.cols[i].width - 2 * padding, self.config.min_width ) end for _, row in ipairs(self.data.rows) do - for i, col in ipairs(row.cols) do + for i, col in ipairs(row.cells) do max_content[i] = math.max(max_content[i], col.width) end end @@ -230,7 +230,7 @@ function Render:compute_layout() local needs_wrap = false for r, row in ipairs(self.data.rows) do local max_lines = 1 - for i, col in ipairs(row.cols) do + for i, col in ipairs(row.cells) do local w = col_widths[i] if w > 0 and col.width > w then local lines = math.ceil(col.width / w) @@ -561,15 +561,15 @@ 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, col in ipairs(row.cols) do - cell_segs[i] = self:cell_segments(col.row, col.start_col, col.end_col) + for i, col in ipairs(row.cells) do + cell_segs[i] = self:cell_segments(col.node.start_row, col.node.start_col, col.node.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.delim.cols) do + 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) From 0e790802baf130b0a43a19356810e76caf619686 Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 10 Mar 2026 17:19:53 -0700 Subject: [PATCH 7/8] feat(table): render segments takes node object; simpler interface --- lua/render-markdown/render/markdown/table.lua | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index 7ee700ca..dd7e33a5 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -307,18 +307,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.offset:range(row, start_col, end_col) @@ -562,7 +560,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, col in ipairs(row.cells) do - cell_segs[i] = self:cell_segments(col.node.start_row, col.node.start_col, col.node.end_col) + cell_segs[i] = self:cell_segments(col.node) end local filler = self.config.filler From 1da76861f4d2ae27bcb5fff2a81fae9aa1d68dda Mon Sep 17 00:00:00 2001 From: Max Dillon Date: Tue, 10 Mar 2026 17:33:37 -0700 Subject: [PATCH 8/8] feat(table): replace nvim_buf_get_lines with node:line --- lua/render-markdown/render/markdown/table.lua | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/lua/render-markdown/render/markdown/table.lua b/lua/render-markdown/render/markdown/table.lua index dd7e33a5..a3f7a1b4 100644 --- a/lua/render-markdown/render/markdown/table.lua +++ b/lua/render-markdown/render/markdown/table.lua @@ -241,10 +241,8 @@ 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 raw_screen_lines = math.max(1, math.ceil(str.width(buf_line) / win_width)) + local _, line = row.node:line('first', 0) + local raw_screen_lines = math.ceil(str.width(line) / win_width) if raw_screen_lines > max_lines then max_lines = raw_screen_lines needs_wrap = true @@ -582,11 +580,10 @@ 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 win_width = env.win.width(self.context.win) - local buf_screen_lines = math.max(1, math.ceil(str.width(buf_line) / win_width)) + local _, buf_line = row.node:line('first', 0) + buf_line = buf_line or '' + local buf_screen_lines = 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