Skip to content

Commit ea5eb20

Browse files
committed
fix detection bug when element ends in blank line
1 parent d393adb commit ea5eb20

File tree

2 files changed

+179
-6
lines changed

2 files changed

+179
-6
lines changed

lua/morph.lua

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -391,16 +391,24 @@ Pos00.__index = Pos00
391391

392392
--- @param row integer 0-based row
393393
--- @param col integer 0-based column
394+
--- @return morph.Pos00
394395
function Pos00.new(row, col) return setmetatable({ row, col }, Pos00) end
395396

396-
function Pos00:__eq(other) return self[1] == other[1] and self[2] == other[2] end
397+
--- @param other unknown
398+
function Pos00:__eq(other)
399+
return type(other) == 'table' and self[1] == other[1] and self[2] == other[2]
400+
end
397401

402+
--- @param other unknown
398403
function Pos00:__lt(other)
404+
if type(other) ~= 'table' then return false end
399405
if self[1] ~= other[1] then return self[1] < other[1] end
400406
return self[2] < other[2]
401407
end
402408

409+
--- @param other unknown
403410
function Pos00:__gt(other)
411+
if type(other) ~= 'table' then return false end
404412
if self[1] ~= other[1] then return self[1] > other[1] end
405413
return self[2] > other[2]
406414
end
@@ -461,6 +469,12 @@ function Extmark.by_id(bufnr, ns, id)
461469
end
462470

463471
--- @private
472+
--- @param bufnr integer
473+
--- @param ns integer
474+
--- @param id integer
475+
--- @param start_row0 integer
476+
--- @param start_col0 integer
477+
--- @param details vim.api.keyset.extmark_details
464478
--- Construct an Extmark from raw API data, normalizing bounds that extend past buffer end.
465479
function Extmark._from_raw(bufnr, ns, id, start_row0, start_col0, details)
466480
local start = Pos00.new(start_row0, start_col0)
@@ -488,6 +502,10 @@ end
488502

489503
--- @private
490504
--- Find all extmarks that overlap with the given region.
505+
--- @param bufnr integer
506+
--- @param ns integer
507+
--- @param start morph.Pos00
508+
--- @param stop morph.Pos00
491509
--- @return morph.Extmark[]
492510
function Extmark._get_in_range(bufnr, ns, start, stop)
493511
local raw_extmarks = vim.api.nvim_buf_get_extmarks(
@@ -509,6 +527,7 @@ end
509527

510528
--- @private
511529
--- Extract the text content covered by this extmark.
530+
--- @return string
512531
function Extmark:_text()
513532
local start, stop = self.start, self.stop
514533
if start == stop then return '' end
@@ -664,6 +683,7 @@ function Morph.markup_to_lines(opts)
664683
-- so we can cache it for on_change handlers later
665684
local text_accumulators = {} --- @type { text: string[] }[]
666685

686+
--- @param s string
667687
local function emit_text(s)
668688
lines[curr_line1] = (lines[curr_line1] or '') .. s
669689
curr_col1 = #lines[curr_line1] + 1
@@ -682,6 +702,7 @@ function Morph.markup_to_lines(opts)
682702
end
683703
end
684704

705+
--- @param node morph.Tree
685706
local function visit(node)
686707
local node_type = tree_type(node)
687708

@@ -942,6 +963,7 @@ function Morph:mount(tree)
942963
-- Callbacks scheduled via ctx:do_after_render() - run after each render
943964
local after_render_callbacks = {} --- @type function[]
944965

966+
--- @param cb function
945967
local function schedule_after_render(cb) table.insert(after_render_callbacks, cb) end
946968

947969
-- Forward declarations for mutual recursion
@@ -1029,7 +1051,11 @@ function Morph:mount(tree)
10291051

10301052
-- Pre-compute "identity keys" for each node so we can match them up
10311053
-- A key combines: type + component function (if any) + explicit key attribute
1032-
local old_keys, new_keys = {}, {}
1054+
--- @type table<integer, string>
1055+
local old_keys = {}
1056+
--- @type table<integer, string>
1057+
local new_keys = {}
1058+
--- @type table<morph.Tree, string>
10331059
local node_key_cache = {}
10341060

10351061
for i, node in ipairs(old_nodes) do
@@ -1092,7 +1118,7 @@ function Morph:mount(tree)
10921118
end
10931119

10941120
--- Reconcile a component node (mount, update, or reuse existing context).
1095-
--- @param old_tree morph.Node[]
1121+
--- @param old_tree morph.Tree
10961122
--- @param new_tag morph.Tag
10971123
reconcile_component = function(old_tree, new_tag)
10981124
local Component = new_tag.name --[[@as morph.Component]]
@@ -1177,7 +1203,7 @@ function Morph:get_elements_at(pos, mode)
11771203
local elements = {} --- @type morph.Element[]
11781204
for _, extmark in ipairs(candidates) do
11791205
local tag = self.text_content.curr.extmark_ids_to_tag[extmark.id]
1180-
if tag and self:_position_intersects_extmark(pos, extmark, mode) then
1206+
if tag and self._position_intersects_extmark(pos, extmark, mode) then
11811207
table.insert(elements, vim.tbl_extend('force', {}, tag, { extmark = extmark }))
11821208
end
11831209
end
@@ -1194,8 +1220,10 @@ end
11941220

11951221
--- @private
11961222
--- Check if a position truly intersects an extmark (Neovim's API is over-inclusive).
1197-
--- @diagnostic disable-next-line: unused
1198-
function Morph:_position_intersects_extmark(pos, extmark, mode)
1223+
--- @param pos morph.Pos00
1224+
--- @param extmark morph.Extmark
1225+
--- @param mode? string
1226+
function Morph._position_intersects_extmark(pos, extmark, mode)
11991227
local start, stop = extmark.start, extmark.stop
12001228

12011229
-- Zero-width extmarks at cursor position are considered intersecting
@@ -1209,6 +1237,15 @@ function Morph:_position_intersects_extmark(pos, extmark, mode)
12091237

12101238
-- Check column bounds on stop row
12111239
if pos[1] == stop[1] then
1240+
-- Special case: on an empty line where extmark ends at column 0,
1241+
-- the cursor at column 0 should be considered inside. This happens when
1242+
-- an element ends with a newline - the cursor on the resulting empty line
1243+
-- has nowhere else to be, so it should still trigger handlers.
1244+
if pos[2] == 0 and stop[2] == 0 then
1245+
local line = vim.api.nvim_buf_get_lines(extmark.bufnr, pos[1], pos[1] + 1, true)[1] or ''
1246+
if #line == 0 then return true end
1247+
end
1248+
12121249
-- In insert mode the cursor is "thin" (between characters), so we include
12131250
-- the position if it's <= stop (cursor can sit "on" the boundary)
12141251
-- In normal mode the cursor is "wide" (occupies a character), so we only
@@ -1245,6 +1282,8 @@ end
12451282
--- @private
12461283
--- Handle a keypress by dispatching to element handlers (innermost first).
12471284
--- Returns the key to execute, or '' to swallow the keypress.
1285+
--- @param mode string
1286+
--- @param lhs string
12481287
function Morph:_dispatch_keypress(mode, lhs)
12491288
local cursor = vim.api.nvim_win_get_cursor(0)
12501289
--- @diagnostic disable-next-line: need-check-nil, assign-type-mismatch

spec/morph_spec.lua

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1559,6 +1559,140 @@ describe('Morph', function()
15591559
assert.are.same('z', result)
15601560
end)
15611561
end)
1562+
1563+
it('triggers handler on trailing empty line when element ends with newline', function()
1564+
with_buf({}, function()
1565+
local r = Morph.new(0)
1566+
local handler_called = false
1567+
1568+
-- Buffer has single element ending with newline:
1569+
-- Line 0: "hello"
1570+
-- Line 1: "" (empty, from trailing \n)
1571+
-- Extmark covers (0,0) to (1,0)
1572+
r:render {
1573+
h('text', {
1574+
id = 'with-newline',
1575+
nmap = {
1576+
['<CR>'] = function()
1577+
handler_called = true
1578+
return ''
1579+
end,
1580+
},
1581+
}, 'hello\n'),
1582+
}
1583+
1584+
assert.are.same({ 'hello', '' }, get_lines())
1585+
1586+
-- Cursor on the trailing empty line should still trigger the handler
1587+
set_cursor { 2, 0 } -- 1-based: row 2, col 0 (the empty line)
1588+
local result = r:_dispatch_keypress('n', '<CR>')
1589+
1590+
assert.are.same('', result)
1591+
assert.is_true(handler_called, 'Handler should be called on trailing empty line')
1592+
end)
1593+
end)
1594+
1595+
it('triggers second element handler when first ends with newline and second follows', function()
1596+
with_buf({}, function()
1597+
local r = Morph.new(0)
1598+
local first_called = false
1599+
local second_called = false
1600+
1601+
-- Buffer:
1602+
-- Line 0: "hello"
1603+
-- Line 1: "world"
1604+
-- Element 'first': (0,0) to (1,0) - ends at start of line 1
1605+
-- Element 'second': (1,0) to (1,5) - covers "world"
1606+
r:render {
1607+
h('text', {
1608+
id = 'first',
1609+
nmap = {
1610+
['<CR>'] = function()
1611+
first_called = true
1612+
return 'first'
1613+
end,
1614+
},
1615+
}, 'hello\n'),
1616+
h('text', {
1617+
id = 'second',
1618+
nmap = {
1619+
['<CR>'] = function()
1620+
second_called = true
1621+
return 'second'
1622+
end,
1623+
},
1624+
}, 'world'),
1625+
}
1626+
1627+
assert.are.same({ 'hello', 'world' }, get_lines())
1628+
1629+
-- Cursor at start of "world" line - should be in second element only
1630+
set_cursor { 2, 0 } -- 1-based: row 2, col 0
1631+
local result = r:_dispatch_keypress('n', '<CR>')
1632+
1633+
assert.are.same('second', result)
1634+
assert.is_false(first_called, 'First handler should NOT be called')
1635+
assert.is_true(second_called, 'Second handler should be called')
1636+
1637+
-- Verify get_elements_at returns only second element in normal mode
1638+
local elems = r:get_elements_at { 1, 0 } -- 0-based
1639+
assert.are.same(1, #elems)
1640+
assert.are.same('second', elems[1].attributes.id)
1641+
end)
1642+
end)
1643+
1644+
it('returns both elements at boundary in insert mode', function()
1645+
with_buf({}, function()
1646+
local r = Morph.new(0)
1647+
1648+
-- Same setup: first element ends with newline, second follows
1649+
r:render {
1650+
h('text', { id = 'first' }, 'hello\n'),
1651+
h('text', { id = 'second' }, 'world'),
1652+
}
1653+
1654+
assert.are.same({ 'hello', 'world' }, get_lines())
1655+
1656+
-- In insert mode, cursor at boundary should see both elements
1657+
local elems = r:get_elements_at({ 1, 0 }, 'i') -- 0-based, insert mode
1658+
assert.are.same(2, #elems)
1659+
-- Second element is "innermost" (starts at cursor), first is "outer" (ends at cursor)
1660+
local ids = vim.tbl_map(function(e) return e.attributes.id end, elems)
1661+
assert.is_true(vim.tbl_contains(ids, 'first'))
1662+
assert.is_true(vim.tbl_contains(ids, 'second'))
1663+
end)
1664+
end)
1665+
1666+
it('does not trigger first element on non-empty line even if extmark ends there', function()
1667+
with_buf({}, function()
1668+
local r = Morph.new(0)
1669+
local first_called = false
1670+
1671+
-- Element 'first' ends at (1,0) but line 1 has content
1672+
r:render {
1673+
h('text', {
1674+
id = 'first',
1675+
nmap = {
1676+
['x'] = function()
1677+
first_called = true
1678+
return ''
1679+
end,
1680+
},
1681+
}, 'hello\n'),
1682+
'world', -- plain text, no handler
1683+
}
1684+
1685+
assert.are.same({ 'hello', 'world' }, get_lines())
1686+
1687+
-- Cursor on "world" line - first element's handler should NOT fire
1688+
set_cursor { 2, 0 }
1689+
local result = r:_dispatch_keypress('n', 'x')
1690+
1691+
-- Should return original key (no handler matched)
1692+
assert.are.same('x', result)
1693+
assert.is_false(first_called)
1694+
end)
1695+
end)
15621696
end)
15631697

15641698
------------------------------------------------------------------------------

0 commit comments

Comments
 (0)