Skip to content

fix(table): correct rendering of wrapped tables with concealed content#481

Draft
ImmanuelHaffner wants to merge 14 commits intoOXY2DEV:mainfrom
ImmanuelHaffner:fix/table-wrap-rendering
Draft

fix(table): correct rendering of wrapped tables with concealed content#481
ImmanuelHaffner wants to merge 14 commits intoOXY2DEV:mainfrom
ImmanuelHaffner:fix/table-wrap-rendering

Conversation

@ImmanuelHaffner
Copy link
Contributor

Summary

Fixes table rendering when set wrap is enabled and table cells contain concealed content (e.g. hyperlinks with long URLs, ~~strikethrough~~). Previously, tables with long raw-text lines were either skipped entirely or rendered with broken borders, misaligned widths, and inconsistent highlights.

This branch addresses 8 related issues across the table renderer, parser, and visual-width calculation.

Changes

  • Use visual width for table wrap check — the renderer compared raw text width (including concealed URLs) against the window width, causing tables to be skipped even though the visual width fit. Now uses col_widths (post-conceal) instead of vim_width.
  • Wrap continuation borders — when a raw buffer line wraps despite concealed content, place │…│ border characters on continuation screen rows via deferred post_render. Uses nvim_win_text_height + binary search with screenpos for accurate wrap detection (inline extmarks from markdown_inline affect visual height, so placement must happen after all renderers finish).
  • Consistent table border highlights — removed is_wrapped guards that fell back to @punctuation.special.markdown; all table borders now use MarkviewTableBorder/MarkviewTableHeader in both wrapped and non-wrapped modes.
  • Right border on first screen row of wrapping lines — the concealed right | ends up on the continuation line, so an additional is placed via virt_text_win_col at the table's visual right edge.
  • Correct top/bottom border indentation in nested tablesorg_indent post-render was cumulative with the table's own col_start indent, causing misaligned borders for tables inside list items.
  • Stabilize inline virt_text ordering — Neovim's mark tree traversal order for inline virt_text is not stable when range marks and point marks share a position. Set right_gravity=false on padding/decoration marks to guarantee consistent ordering in hybrid mode.
  • Handle strikethrough in visual width calculation~~text~~ markers were included in column width computation, inflating widths by 4 characters. Added strikethrough handler to tostring.lua.
  • Strip blockquote prefix from table rows in parserget_node_text() only applies col_start offset to the first line; subsequent lines inside > blockquotes retained the prefix, causing the row parser to fail silently.

Regression Matrix

Tested with set wrap enabled against test/stress.md and test/regression-examples.md.

Fixes

# Behavior Worked Before Works Now
1 Tables with concealed content (long URLs) render instead of being skipped
2 Wrap continuation lines show table borders instead of raw text leaking
3 ~~strikethrough~~ doesn't inflate column widths
4 Table borders use MarkviewTable* highlights (not TS punctuation hl)
5 Right border appears on first screen row of wrapping lines N/A
6 Top/bottom border indentation correct for nested tables (list context)
7 Separator row decorations don't swap order when cursor moves (hybrid mode) N/A
8 Tables inside blockquotes render separator + data rows

No Regressions

# Behavior Worked Before Works Now
9 Simple table renders correctly (set nowrap)
10 Alignment markers render correctly
11 Blockquote borders render alongside table borders
12 Hybrid mode: cursor-line un-renders/re-renders cleanly
13 Code blocks inside lists render correctly
14 Headings, horizontal rules, inline formatting unaffected

@ImmanuelHaffner
Copy link
Contributor Author

Before

Screenshot 2026-03-19 at 10 37 43

After

Screenshot 2026-03-19 at 10 36 45

@ImmanuelHaffner ImmanuelHaffner marked this pull request as ready for review March 19, 2026 09:39
@ImmanuelHaffner
Copy link
Contributor Author

There is still an issue with the Alignment Torture table having its last column rendered way too wide. But i'd like to address this as a follow up.

@OXY2DEV
Copy link
Owner

OXY2DEV commented Mar 19, 2026

Screenshot_2026-03-19-18-58-05-359_com termux

This is from the README btw.


This seems like a high maintenance feature.

If you aren't aware, Vim's/Neovim's wrap logic isn't widely documented and it is affected by way too many factors.

I don't think the fix should come from markview, it should be coming from Neovim. Unless they expose where text is wrapped there will always be edge cases.

@ImmanuelHaffner
Copy link
Contributor Author

This is from the README btw.

Screenshot 2026-03-19 at 14 27 24

It renders just fine for me. It's broken in this branch because the other PR i filed has the necessary fix, and i am testing with both patches here.

If you aren't aware, Vim's/Neovim's wrap logic isn't widely documented and it is affected by way too many factors.

I don't think the fix should come from markview, it should be coming from Neovim. Unless they expose where text is wrapped there will always be edge cases.

I am aware, and I intend to at least investigate how much work it would be to fix this in Neovim, i.e. computing and using the visual width of the concealed text to trigger wrapping. But i think my patch nicely works around this issue by placing extmarks for simply vertically extending the cell borders so the table does not visually break.

@OXY2DEV
Copy link
Owner

OXY2DEV commented Mar 19, 2026

@ImmanuelHaffner you have to test this for every single screen width otherwise it won't work universally. That's the reason this hasn't been implemented. It's really hard to get it right for all screen sizes.

@OXY2DEV
Copy link
Owner

OXY2DEV commented Mar 19, 2026

I am looking at 4a7b3de and do we have to use screenpos()? It's too performance intensive and last I used it, it was too slow and made rendering sluggish on lower end devices.

@ImmanuelHaffner
Copy link
Contributor Author

you have to test this for every single screen width otherwise it won't work universally. That's the reason this hasn't been implemented. It's really hard to get it right for all screen sizes.

@OXY2DEV Not sure i fully understand this requirement. I tried resizing the window and calling :Markview render, and the table would always render nicely when the visual area is as wide as the concealed and rendered table.
When the visual area gets more narrow, rendering for the table is skipped.

This behavior seems fine to me.

What is odd is that i have to call :Markview render to re-render after a resize. I guess it's some effort of Markview to save work?

@OXY2DEV
Copy link
Owner

OXY2DEV commented Mar 19, 2026

Not sure i fully understand this requirement.

Basically, adding stuff to the window(e.g. a wider statuscolumn) can mess up the calculations. So, adding N to the start column to guess where text wrapped will not work in those cases and more often than not they tend to make the effect worse when there's too many wraps in a single line.

You should test lines that have 2 or more wrapped lines and with different terminal sizes.

@OXY2DEV
Copy link
Owner

OXY2DEV commented Mar 19, 2026

What is odd is that i have to call :Markview render to re-render after a resize. I guess it's some effort of Markview to save work?

That's expected since in most cases re-drawing wouldn't have any visual changes.

@ImmanuelHaffner
Copy link
Contributor Author

Basically, adding stuff to the window(e.g. a wider statuscolumn) can mess up the calculations. So, adding N to the start column to guess where text wrapped will not work in those cases and more often than not they tend to make the effect worse when there's too many wraps in a single line.

Do you mean tests like that?

Long URL with multiple line wraps inside a table

Not rendered

Screenshot 2026-03-19 at 17 13 36

Rendered: Sign + Number column on the left

Screenshot 2026-03-19 at 17 13 44

Rendered: No number column (empty sign column)

Screenshot 2026-03-19 at 17 14 32

Rendered: No sign and number columns

Screenshot 2026-03-19 at 17 14 51

Rendered: Extra wide sign column

Screenshot 2026-03-19 at 17 15 04

I can see that the URL alt text is sometimes not renderd inside the table and we only see this ghost uline.

But i suspect that this is a rendering bug of the URLs and image URLs, not the table. See the odd rendering of a link here:

Screenshot 2026-03-19 at 17 22 02

@OXY2DEV
Copy link
Owner

OXY2DEV commented Mar 20, 2026

Yeah, in the 2nd image you can see that TS from the link is being covered up by the spaces.

@OXY2DEV
Copy link
Owner

OXY2DEV commented Mar 20, 2026

I have tested a bit more and there's a few issues,

  • Border rendering is still quite finicky.
Screenshot_2026-03-20-09-56-21-032_com termux Screenshot_2026-03-20-09-56-31-049_com termux

It's especially pronounced in tight window size(I am using :set columns=30 here).

  • Sometimes text gets hidden if wrapping starts at one column and ends at a column before it.

@OXY2DEV
Copy link
Owner

OXY2DEV commented Mar 20, 2026

I would still advise against using screenpos() as during testing I found that it can quickly slow down rendering.

I am using 50 3 row 2 column tables containing image links with wrap enabled.

@ImmanuelHaffner
Copy link
Contributor Author

ImmanuelHaffner commented Mar 20, 2026

Thanks for your feedback. I will look into screenpos() performance and how we can avoid it. I will also try to repro the table breakage you showed and find a solution. I'm still encouraged to solve this cleanly and get the patch in :)

@ImmanuelHaffner ImmanuelHaffner force-pushed the fix/table-wrap-rendering branch from 675be20 to ceecae4 Compare March 20, 2026 06:56
@OXY2DEV
Copy link
Owner

OXY2DEV commented Mar 20, 2026

@ImmanuelHaffner I think you missed an issue with screeenpos().

It doesn't work for text outside of the visible part of the window. So, when a buffer is fully rendered, things that are outside of the visible part of the window have missing table borders.


I would encourage you to fix these issues in wrap.lua itself first since these issues have only been partially fixed for other elements(mainly lists, block quotes etc.) and if it can't be fixed for other simpler elements it's most likely wouldn't be fixed for tables.

@ImmanuelHaffner
Copy link
Contributor Author

I just rebased the PR after you merged my other fix and other PRs. Still working on it

@ImmanuelHaffner ImmanuelHaffner marked this pull request as draft March 20, 2026 09:04
@ImmanuelHaffner ImmanuelHaffner force-pushed the fix/table-wrap-rendering branch from ceecae4 to 62c7191 Compare March 20, 2026 14:17
@ImmanuelHaffner
Copy link
Contributor Author

ImmanuelHaffner commented Mar 20, 2026

I filed two more PRs for fixes in the inline renderer. With them in, the table rendering now feels much more robust.

PTAL. You can still see that "ghost" underline being wrapped in the table, but at least it does not break the table rendering.

Screenshot 2026-03-20 at 15 16 35

The table wrap check used vim_width (raw text width including concealed
content like URLs) to decide whether to skip rendering. This caused
tables with long hyperlinks to not render at all when wrap is enabled,
even though the visual width after conceal was well within the window.

Use col_widths (visual width after conceal resolution) instead, which
correctly reflects the rendered table width.
Add strikethrough handler to strip ~~ markers when computing visual text
width. Without this, ~~text~~ in table cells inflated column widths by
4 extra characters (2 per marker pair).

- Add md_str.strikethrough() to strip ~~ delimiters
- Add LPEG strike pattern for ~~content~~ syntax
- Register strike in the token alternatives
When a table row contains long concealed text (e.g. URLs), the raw
buffer line wraps even though the visual content fits. This places
table border characters (│...│) on wrap continuation screen rows to
preserve the visual table structure.

The placement is deferred to post_render (markdown.__table) which runs
after all renderers including markdown_inline. This is critical because
inline extmarks (padding, conceal) affect the visual line height — if
checked too early, nvim_win_text_height reports no wrapping for lines
that will wrap once inline extmarks are added.

Key design decisions:
- Use nvim_win_text_height for accurate wrap detection (accounts for
  inline extmarks and linebreak, unlike strdisplaywidth which is
  context-dependent with linebreak=true)
- Use binary search with screenpos for precise wrap boundary positions
  (respects linebreak word-boundary wrapping)
- Store continuation_vt on the item during markdown.table, register
  in markdown.cache for post_render dispatch
…mode

Remove is_wrapped guards that skipped pipe conceal/replacement and
overrode highlights with @punctuation.special.markdown. Table borders
now consistently use markview's own highlight groups (MarkviewTableBorder,
MarkviewTableHeader) and fancy border characters (│) in both wrapped
and non-wrapped modes.

Concealing | and replacing with │ (same display width) does not affect
Neovim's line wrapping behavior, so the guards were unnecessary.
When a table row wraps, the concealed right pipe (│) ends up on the
continuation screen line, leaving the first screen row without a right
border. Fix by placing an additional │ via virt_text_win_col at the
table's right edge (table_width - 1) on the first screen row.

Stores right border virt_text and table visual width on the item during
markdown.table, then places the overlay in markdown.__table post_render.
When a table appears inside an indented block (e.g. list items), the
org_indent post-render adds visual indentation to lines. The top/bottom
border extmarks included their own col_start-based indent, which was
cumulative with org_indent — causing the top border to be indented too
far and the bottom border not enough.

Fix by computing the target visual indent from the first data row's
org_indent marks in __table post_render, then adjusting each border's
leading spaces: border_leading = target - border_line_org_indent.

Also saves top/bottom border extmark IDs on the item so __table can
find and update them. Both the separator and missing_separator code
paths now store the bottom border ID.
… renderer

Neovim's mark tree traversal order is not stable for inline virt_text when
range marks (conceal+border) and point marks (padding/decoration) coexist
at the same (row, col). The traversal order depends on the internal tree
structure, which varies with the total number of marks in the buffer.

In hybrid mode, filtering out preceding content changes how many marks are
created, shifting the mark tree structure and causing padding/decoration
marks to swap visual order with junction/border marks at shared positions.
This made table borders appear misplaced when the cursor was on a heading
or inside a nearby table.

Fix: set right_gravity=false on all inline padding/decoration marks at
col_end positions (header row and separator row). Neovim sorts
right_gravity=false before right_gravity=true at the same position,
guaranteeing stable traversal order regardless of tree structure.
get_node_text() only applies col_start offset to the first line of a
tree-sitter node. For tables inside blockquotes, lines 2+ retain the
'> ' prefix, causing the lpeg row parser to fail silently and produce
empty results. This left separator and data rows unrendered.

Strip the prefix via line:sub(col_start + 1) for lines after the first,
and skip empty/blank lines (e.g. trailing '>' markers).
Covers feature matrix tables, alignment torture (left/center/right),
nested structures (lists, blockquotes, code blocks), inline chaos,
fenced blocks, horizontal rules, single-column tables, tables inside
blockquotes, and math blocks.
…rap detection

Use nvim_win_text_height and virtcol2col instead of screenpos to locate
wrap line boundaries in table continuation border rendering. This avoids
reliance on screen state and works correctly when the window is not
visible or during deferred rendering.
Replace the binary-search approach for placing continuation borders on
wrapped table lines with an analytical walk over the parsed table
structure.  The old method relied on virtcol, which ignores extmark
conceal and caused misaligned borders when cells contained concealed
elements (e.g. CommonMark links with long URLs).

Additionally:
- Remove unused vim_width tracking across three column-width loops
- Fix wrap detection to account for textoff (sign/number columns)
  and compare rendered table width against the usable text area
- Store col_widths on the item for use in the post_render phase
- Accept minor wrap artefacts from unconcealed soft-wrap rather
  than bailing out of rendering entirely
…ders

Replace the analytical walk (which used rendered/concealed column
widths) with a binary search using nvim_win_text_height and vcol
parameters.  The analytical walk underestimated wrap boundaries
because Neovim wraps based on raw text width + inline virt_text,
ignoring extmark conceal — so concealed URLs still count towards
the wrap width.

nvim_win_text_height with start_vcol/end_vcol operates in the same
coordinate space Neovim uses for wrapping, making it the correct
predicate for locating wrap boundaries.

- Use height.all * text_width as upper bound (safe, exceeds the
  effective wrap width including inline virt_text additions)
- Convert vcol to byte position via virtcol2col for extmark anchor
- Remove unused __col_widths storage from table items
…st underlines

Remove hl_mode="combine" from the concealing extmarks in
link_hyperlink and link_image.  When a link URL is concealed, the
virtual text highlight (e.g. underline) bled across every concealed
byte, producing ghost underlines on phantom screen rows created by
soft-wrap of the hidden text.

Add inline conceal torture section to stress test.
@ImmanuelHaffner ImmanuelHaffner force-pushed the fix/table-wrap-rendering branch from 62c7191 to 7c6421a Compare March 20, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants