Skip to content

Commit b4885a9

Browse files
fix: use display width of concealed ranges
## Details When computing widths for concealed text in highlights we were correctly using the display width of the associated node's text. However, when accounting for conceal `extmark`s added by this plugin we simply used the difference between start & end column, which is the size in bytes. This meant that if something concealed by this plugin contained glyphs which are displayed shorter than the number of bytes (common in many languages) we would incorrectly calculate offsets. Fixing this is a little involved since we need access to the actual text that is being concealed, however when adding the mark all we have is the range. We can get around this since we do have access to the text and start / end columns when we are computing the widths. So we no longer store the width information directly, instead we store the concealed ranges along with some additional metadata, find the overlap of these ranges with the text, then calculate the width of the text associated with any overlaps. To make this all work we needed to be much better about how ranges were stored and added. We do a coalesce operation (similar to view ranges) each time a new one is added at the row level, when we combine a range with another we include the combined conceal characters and the number of individual blocks that made the combination. This information is needed to correctly handle different conceal level behaviors. The `interval` module `overlaps` method has been replaced with an `overlap` method which returns the region associated with an overlap rather than just a `boolean`. This was needed for the new functionality, existing usage was replaced since checking if `overlap` is non-nil does the same thing. Minor quality of life improvement for unit testing, the `expected` marks are now sorted using the same logic as the `actual` marks before being compared. This means the order information is added does not matter for correctness allowing us to share common marks as needed. For instance for many of the `table` related tests the offsets and such are different but the pipe related marks all occur at the same absolute locations, so we can create a method to compute those and include them where needed.
1 parent 44cbac6 commit b4885a9

File tree

17 files changed

+606
-636
lines changed

17 files changed

+606
-636
lines changed

doc/render-markdown.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
*render-markdown.txt* For NVIM v0.11.4 Last change: 2025 September 10
1+
*render-markdown.txt* For NVIM v0.11.4 Last change: 2025 September 11
22

33
==============================================================================
44
Table of Contents *render-markdown-table-of-contents*

lua/render-markdown/health.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ local state = require('render-markdown.state')
55
local M = {}
66

77
---@private
8-
M.version = '8.8.3'
8+
M.version = '8.8.4'
99

1010
function M.check()
1111
M.start('versions')

lua/render-markdown/lib/extmark.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ function Extmark:overlaps(range)
3434
if not range then
3535
return false
3636
end
37-
return interval.overlaps(self.range, range)
37+
return interval.overlap(self.range, range) ~= nil
3838
end
3939

4040
---@param ns integer
Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
---@class render.md.Range
2-
---@field [1] integer
3-
---@field [2] integer
2+
---@field [1] integer start
3+
---@field [2] integer end
44

55
---@class render.md.Interval
66
local M = {}
77

8+
---@param range render.md.Range
9+
---@param exclusive? boolean
10+
---@return boolean
11+
function M.valid(range, exclusive)
12+
if exclusive then
13+
return range[1] < range[2]
14+
else
15+
return range[1] <= range[2]
16+
end
17+
end
18+
819
---noncommutative
920
---@param a render.md.Range
1021
---@param b render.md.Range
@@ -17,25 +28,20 @@ end
1728
---@param a render.md.Range
1829
---@param b render.md.Range
1930
---@param exclusive? boolean
20-
---@return boolean
21-
function M.overlaps(a, b, exclusive)
22-
if exclusive then
23-
return b[1] < a[2] and b[2] > a[1]
24-
else
25-
return b[1] <= a[2] and b[2] >= a[1]
26-
end
31+
---@return render.md.Range?
32+
function M.overlap(a, b, exclusive)
33+
---@type render.md.Range
34+
local result = {
35+
math.max(a[1], b[1]),
36+
math.min(a[2], b[2]),
37+
}
38+
return M.valid(result, exclusive) and result or nil
2739
end
2840

2941
---@param ranges render.md.Range[]
3042
---@return render.md.Range[]
3143
function M.coalesce(ranges)
32-
table.sort(ranges, function(a, b)
33-
if a[1] ~= b[1] then
34-
return a[1] < b[1]
35-
else
36-
return a[2] < b[2]
37-
end
38-
end)
44+
M.sort(ranges)
3945
local result = {} ---@type render.md.Range[]
4046
result[#result + 1] = ranges[1]
4147
for i = 2, #ranges do
@@ -49,4 +55,15 @@ function M.coalesce(ranges)
4955
return result
5056
end
5157

58+
---@param ranges render.md.Range[]
59+
function M.sort(ranges)
60+
table.sort(ranges, function(a, b)
61+
if a[1] ~= b[1] then
62+
return a[1] < b[1]
63+
else
64+
return a[2] < b[2]
65+
end
66+
end)
67+
end
68+
5269
return M

lua/render-markdown/lib/marks.lua

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,19 +123,15 @@ end
123123
---@private
124124
---@param mark render.md.Mark
125125
function Marks:run_update(mark)
126-
local row, start_col = mark.start_row, mark.start_col
127-
if mark.opts.conceal then
128-
local end_col = assert(mark.opts.end_col, 'conceal requires end_col')
129-
self.context.conceal:add(row, {
130-
col = { start_col, end_col },
131-
width = end_col - start_col,
132-
character = mark.opts.conceal,
133-
})
126+
local row, start_col, opts = mark.start_row, mark.start_col, mark.opts
127+
if opts.conceal then
128+
local end_col = assert(opts.end_col, 'conceal requires end_col')
129+
self.context.conceal:add(row, { start_col, end_col, opts.conceal, 1 })
134130
end
135-
if mark.opts.virt_text_pos == 'inline' then
131+
if opts.virt_text_pos == 'inline' then
136132
self.context.offset:add(row, {
137133
col = start_col,
138-
width = str.line_width(mark.opts.virt_text),
134+
width = str.line_width(opts.virt_text),
139135
})
140136
end
141137
end

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ function Render:shift(col, side, amount)
333333
virt_text_pos = 'inline',
334334
})
335335
elseif amount < 0 then
336-
amount = amount - self.context.conceal:width('')
336+
amount = amount - self.context.conceal:width('', 1)
337337
self.marks:add(self.config, true, col.row, column + amount, {
338338
priority = 0,
339339
end_col = column,

lua/render-markdown/request/conceal.lua

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@ local str = require('render-markdown.lib.str')
55

66
---@class render.md.request.conceal.Line
77
---@field hidden boolean
8-
---@field sections render.md.request.conceal.Section[]
8+
---@field ranges render.md.request.conceal.Range[]
99

10-
---@class render.md.request.conceal.Section
11-
---@field col render.md.Range
12-
---@field width integer
13-
---@field character? string
10+
---@class render.md.request.conceal.Range: render.md.Range
11+
---@field [3] string replacement
12+
---@field [4] integer blocks
1413

1514
---@class render.md.request.Conceal
1615
---@field private buf integer
@@ -41,46 +40,55 @@ function Conceal:enabled()
4140
end
4241

4342
---@param row integer
44-
---@param entry boolean|render.md.request.conceal.Section
43+
---@param entry boolean|render.md.request.conceal.Range
4544
function Conceal:add(row, entry)
4645
if not self:enabled() then
4746
return
4847
end
4948
if not self.lines[row] then
50-
self.lines[row] = { hidden = false, sections = {} }
49+
self.lines[row] = { hidden = false, ranges = {} }
5150
end
5251
local line = self.lines[row]
5352
if type(entry) == 'boolean' then
5453
line.hidden = entry
5554
else
56-
if entry.width > 0 and not Conceal.contains(line, entry) then
57-
line.sections[#line.sections + 1] = entry
55+
if interval.valid(entry, true) then
56+
line.ranges[#line.ranges + 1] = entry
57+
line.ranges = Conceal.coalesce(line.ranges)
5858
end
5959
end
6060
end
6161

6262
---@private
63-
---@param line render.md.request.conceal.Line
64-
---@param entry render.md.request.conceal.Section
65-
---@return boolean
66-
function Conceal.contains(line, entry)
67-
for _, section in ipairs(line.sections) do
68-
if interval.contains(section.col, entry.col) then
69-
return true
63+
---@param ranges render.md.request.conceal.Range[]
64+
---@return render.md.request.conceal.Range[]
65+
function Conceal.coalesce(ranges)
66+
interval.sort(ranges)
67+
local result = {} ---@type render.md.request.conceal.Range[]
68+
result[#result + 1] = ranges[1]
69+
for i = 2, #ranges do
70+
local range, last = ranges[i], result[#result]
71+
if range[1] <= last[2] then
72+
last[2] = math.max(last[2], range[2])
73+
last[3] = last[3] .. range[3]
74+
last[4] = last[4] + range[4]
75+
else
76+
result[#result + 1] = range
7077
end
7178
end
72-
return false
79+
return result
7380
end
7481

75-
---@param character? string
82+
---@param s string
83+
---@param blocks integer
7684
---@return integer
77-
function Conceal:width(character)
85+
function Conceal:width(s, blocks)
7886
if self.level == 1 then
7987
-- each block is replaced with one character
80-
return 1
88+
return blocks
8189
elseif self.level == 2 then
82-
-- replacement character width is used
83-
return str.width(character)
90+
-- replacement characters width is used
91+
return str.width(s)
8492
else
8593
-- text is completely hidden
8694
return 0
@@ -98,10 +106,15 @@ end
98106
---@return integer
99107
function Conceal:get(body)
100108
local result = 0
101-
local col = { body.start_col, body.end_col } ---@type render.md.Range
102-
for _, section in ipairs(self:line(body).sections) do
103-
if interval.overlaps(section.col, col, true) then
104-
local width = section.width - self:width(section.character)
109+
local target = { body.start_col, body.end_col } ---@type render.md.Range
110+
for _, range in ipairs(self:line(body).ranges) do
111+
local overlap = interval.overlap(range, target, true)
112+
if overlap then
113+
local text = body.text:sub(
114+
overlap[1] - target[1] + 1,
115+
overlap[2] - target[1]
116+
)
117+
local width = str.width(text) - self:width(range[3], range[4])
105118
result = result + width
106119
end
107120
end
@@ -118,7 +131,7 @@ function Conceal:line(body)
118131
end
119132
local line = self.lines[body.start_row]
120133
if not line then
121-
line = { hidden = false, sections = {} }
134+
line = { hidden = false, ranges = {} }
122135
end
123136
return line
124137
end
@@ -162,12 +175,7 @@ function Conceal:tree(language, root)
162175
end
163176
if data.conceal then
164177
local row, start_col, _, end_col = Conceal.range(id, node, data)
165-
local text = vim.treesitter.get_node_text(node, self.buf)
166-
self:add(row, {
167-
col = { start_col, end_col },
168-
width = str.width(text),
169-
character = data.conceal,
170-
})
178+
self:add(row, { start_col, end_col, data.conceal, 1 })
171179
end
172180
end)
173181
end

lua/render-markdown/request/view.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ end
4848
function View:overlaps(node)
4949
local start_row, _, end_row = node:range()
5050
for _, range in ipairs(self.ranges) do
51-
if interval.overlaps(range, { start_row, end_row }) then
51+
if interval.overlap(range, { start_row, end_row }) then
5252
return true
5353
end
5454
end

0 commit comments

Comments
 (0)