Skip to content

Commit 3553ea5

Browse files
committed
fix(table): wrap at word boundary
1 parent 232a1d1 commit 3553ea5

2 files changed

Lines changed: 117 additions & 42 deletions

File tree

lua/render-markdown/render/markdown/table.lua

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -249,14 +249,11 @@ function Render:compute_layout()
249249
local max_lines = 1
250250
for i, cell in ipairs(row.cells) do
251251
local w = col_widths[i]
252-
local width = content_width(cell)
253-
if w > 0 and width > w then
254-
local lines = math.ceil(width / w)
255-
if lines > max_lines then
256-
max_lines = lines
257-
end
258-
needs_wrap = true
252+
local lines = #self:wrap_line(self:cell_line(cell.node), w)
253+
if lines > max_lines then
254+
max_lines = lines
259255
end
256+
needs_wrap = needs_wrap or lines > 1
260257
end
261258
row_heights[r] = max_lines
262259
end
@@ -320,6 +317,15 @@ function Render.alignment(node)
320317
end
321318
end
322319

320+
---@private
321+
---@param node render.md.Node
322+
---@return render.md.Line
323+
function Render:cell_line(node)
324+
local line = Line.new(self.config.filler)
325+
vim.list_extend(line:get(), self:cell_segments(node))
326+
return line
327+
end
328+
323329
---Compute display segments for a cell: raw text − concealed + injected,
324330
---with treesitter highlight groups preserved.
325331
---@private
@@ -605,6 +611,76 @@ function Render:row(row)
605611
end
606612
end
607613

614+
---@private
615+
---@param line render.md.Line
616+
---@param width integer
617+
---@return render.md.Line[]
618+
function Render:wrap_line(line, width)
619+
if width <= 0 then
620+
return { Line.new(self.config.filler) }
621+
end
622+
623+
local total = line:width()
624+
if total == 0 then
625+
return { Line.new(self.config.filler) }
626+
end
627+
628+
local spaces = {} ---@type table<integer, boolean>
629+
local column = 1
630+
for _, segment in ipairs(line:get()) do
631+
local text = segment[1]
632+
local bytes = vim.str_utf_pos(text)
633+
for index, start_byte in ipairs(bytes) do
634+
local end_byte = index < #bytes and bytes[index + 1] - 1 or #text
635+
local char = text:sub(start_byte, end_byte)
636+
local width = str.width(char)
637+
if char:match('^%s$') then
638+
spaces[column] = true
639+
end
640+
column = column + width
641+
end
642+
end
643+
644+
---@param column integer
645+
---@return boolean
646+
local function is_space(column)
647+
return spaces[column] == true
648+
end
649+
650+
local result = {} ---@type render.md.Line[]
651+
local start = 1
652+
while start <= total do
653+
while start <= total and is_space(start) do
654+
start = start + 1
655+
end
656+
if start > total then
657+
break
658+
end
659+
660+
local limit = math.min(start + width - 1, total)
661+
local chunk_end = limit
662+
local next_start = limit + 1
663+
if limit < total then
664+
for column = limit, start, -1 do
665+
if is_space(column) then
666+
chunk_end = column - 1
667+
next_start = column + 1
668+
break
669+
end
670+
end
671+
end
672+
if chunk_end < start then
673+
chunk_end = limit
674+
next_start = limit + 1
675+
end
676+
677+
result[#result + 1] = line:sub(start, chunk_end)
678+
start = next_start
679+
end
680+
681+
return #result > 0 and result or { Line.new(self.config.filler) }
682+
end
683+
608684
---@private
609685
---@param row render.md.table.Row
610686
---@param row_index integer
@@ -618,10 +694,11 @@ function Render:row_wrapped_lines(row, row_index)
618694
local spaces =
619695
math.max(str.spaces('start', row.node.text), row.node.start_col)
620696

621-
-- Pre-compute display segments for each cell in this row
622-
local cell_segs = {} ---@type render.md.mark.Line[]
697+
-- Pre-compute wrapped display lines for each cell in this row
698+
local cell_lines = {} ---@type render.md.Line[][]
623699
for i, cell in ipairs(row.cells) do
624-
cell_segs[i] = self:cell_segments(cell.node)
700+
cell_lines[i] =
701+
self:wrap_line(self:cell_line(cell.node), self.layout.col_widths[i])
625702
end
626703

627704
local filler = self.config.filler
@@ -633,12 +710,7 @@ function Render:row_wrapped_lines(row, row_index)
633710
local col_width = self.layout.col_widths[i]
634711
line:text(border_icon, highlight)
635712
line:pad(padding, filler)
636-
local cell_line = Line.new(filler)
637-
vim.list_extend(cell_line:get(), cell_segs[i] or {})
638-
local chunk = cell_line:sub(
639-
visual_line * col_width + 1,
640-
(visual_line + 1) * col_width
641-
)
713+
local chunk = cell_lines[i][visual_line + 1] or Line.new(filler)
642714
line:extend(chunk)
643715
line:pad(col_width - chunk:width(), filler)
644716
line:pad(padding, filler)

tests/table_spec.lua

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,9 @@ describe('table', function()
307307
'┌──────┬────────────────────────────────────────┬──────────┐',
308308
'│ ID │ Title │ Status │',
309309
'├──────┼────────────────────────────────────────┼──────────┤',
310-
'│ 1 │ This sentence is long enough to wrap a │ Open │',
311-
'│ │ cross several rendered table lines wit │ │',
312-
'│ │ hout needing a trailing blank line. │ │',
310+
'│ 1 │ This sentence is long enough to wrap │ Open │',
311+
'│ │ across several rendered table lines │ │',
312+
'│ │ without needing a trailing blank line. │ │',
313313
'└──────┴────────────────────────────────────────┴──────────┘',
314314
})
315315
end)
@@ -334,12 +334,13 @@ describe('table', function()
334334
' │ Approach │ Allocations │ Performance │',
335335
' ├────────────────────┼────────────────┼───────────────┤',
336336
' │ format!() in loop │ N │ Slow │',
337-
' │ write!() to reused │ 1 │ Fast │',
338-
' │ buffer │ │ │',
339-
' │ push_str() + push( │ 1 │ Fastest │',
340-
' │ ) │ │ │',
341-
' │ Pre-sized String:: │ 1 (no realloc) │ Fast │',
342-
' │ with_capacity() │ │ │',
337+
' │ write!() to │ 1 │ Fast │',
338+
' │ reused buffer │ │ │',
339+
' │ push_str() + │ 1 │ Fastest │',
340+
' │ push() │ │ │',
341+
' │ Pre-sized │ 1 (no realloc) │ Fast │',
342+
' │ String::with_capac │ │ │',
343+
' │ ity() │ │ │',
343344
' └────────────────────┴────────────────┴───────────────┘',
344345
})
345346
end)
@@ -360,23 +361,25 @@ describe('table', function()
360361
'┌─────────┬────────────────────────┬────────────┬──────────┐',
361362
'│ ID │ Title │ Severity │ Status │',
362363
'├─────────┼────────────────────────┼────────────┼──────────┤',
363-
"│ T-1 │ Here's a long text tha │ High │ Open │",
364-
'│ │ t definitely causes wr │ │ │',
365-
'│ │ apping. My hands are t │ │ │',
366-
"│ │ yping words. If you're │ │ │",
367-
'│ │ reading this then my │ │ │',
368-
'│ │ hands continued typing │ │ │',
369-
'│ │ words to make long en │ │ │',
370-
'│ │ ough a line for testin │ │ │',
371-
'│ │ g. Also you possess th │ │ │',
372-
'│ │ e highly coveted skill │ │ │',
373-
'│ │ of reading. Good for │ │ │',
374-
"│ │ you. Sorry, didn't mea │ │ │",
375-
'│ │ n to sound so sarcasti │ │ │',
376-
"│ │ c there. Honestly I'm │ │ │",
377-
'│ │ thrilled for you. I ha │ │ │',
378-
'│ │ ve just trouble showin │ │ │',
379-
'│ │ g it. │ │ │',
364+
"│ T-1 │ Here's a long text │ High │ Open │",
365+
'│ │ that definitely │ │ │',
366+
'│ │ causes wrapping. My │ │ │',
367+
'│ │ hands are typing │ │ │',
368+
"│ │ words. If you're │ │ │",
369+
'│ │ reading this then my │ │ │',
370+
'│ │ hands continued │ │ │',
371+
'│ │ typing words to make │ │ │',
372+
'│ │ long enough a line │ │ │',
373+
'│ │ for testing. Also you │ │ │',
374+
'│ │ possess the highly │ │ │',
375+
'│ │ coveted skill of │ │ │',
376+
'│ │ reading. Good for │ │ │',
377+
"│ │ you. Sorry, didn't │ │ │",
378+
'│ │ mean to sound so │ │ │',
379+
'│ │ sarcastic there. │ │ │',
380+
"│ │ Honestly I'm thrilled │ │ │",
381+
'│ │ for you. I have just │ │ │',
382+
'│ │ trouble showing it. │ │ │',
380383
'└─────────┴────────────────────────┴────────────┴──────────┘',
381384
})
382385
end)

0 commit comments

Comments
 (0)