From 407347bfbcbc375a1ed9a740414c258d7d511792 Mon Sep 17 00:00:00 2001 From: Jonathan Apodaca Date: Wed, 26 Feb 2025 23:36:00 -0700 Subject: [PATCH] use solely extmarks --- lua/u/buffer.lua | 4 +- lua/u/renderer.lua | 199 ++++++++++++++++++++++------------------- spec/renderer_spec.lua | 56 +----------- 3 files changed, 113 insertions(+), 146 deletions(-) diff --git a/lua/u/buffer.lua b/lua/u/buffer.lua index a9e499d..e70306a 100644 --- a/lua/u/buffer.lua +++ b/lua/u/buffer.lua @@ -80,7 +80,7 @@ function Buffer:autocmd(event, opts) vim.api.nvim_create_autocmd(event, vim.tbl_extend('force', opts, { buffer = self.buf })) end ---- @param markup string -function Buffer:render(markup) return self.renderer:render(markup) end +--- @param tree Tree +function Buffer:render(tree) return self.renderer:render(tree) end return Buffer diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua index 9487660..c2cd871 100644 --- a/lua/u/renderer.lua +++ b/lua/u/renderer.lua @@ -8,15 +8,14 @@ local TagMetaTable = {} -------------------------------------------------------------------------------- -- Renderer class -------------------------------------------------------------------------------- ---- @alias RendererHighlight { group: string; start: [number, number]; stop: [number, number ] } +--- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any } --- @class Renderer --- @field bufnr number --- @field ns number --- @field changedtick number ---- @field old { lines: string[]; hls: RendererHighlight[] } ---- @field curr { lines: string[]; hls: RendererHighlight[] } ---- @field tag_infos { tag:Tag[]; start0: [number, number]; stop0: [number, number] }[] +--- @field old { lines: string[]; extmarks: RendererExtmark[] } +--- @field curr { lines: string[]; extmarks: RendererExtmark[] } local Renderer = {} Renderer.__index = Renderer @@ -49,14 +48,16 @@ end function Renderer.new(bufnr) if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end - if vim.b[bufnr]._renderer_ns == nil then vim.b[bufnr]._renderer_ns = vim.api.nvim_create_namespace '' end + if vim.b[bufnr]._renderer_ns == nil then + vim.b[bufnr]._renderer_ns = vim.api.nvim_create_namespace('my.renderer:' .. tostring(bufnr)) + end local self = setmetatable({ bufnr = bufnr, ns = vim.b[bufnr]._renderer_ns, changedtick = 0, - old = { lines = {}, hls = {} }, - curr = { lines = {}, hls = {} }, + old = { lines = {}, extmarks = {} }, + curr = { lines = {}, extmarks = {} }, }, Renderer) return self end @@ -129,74 +130,45 @@ function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_ function Renderer:render(tree) local changedtick = vim.b[self.bufnr].changedtick if changedtick ~= self.changedtick then - self.curr = { lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false), hls = {} } + self.curr = { lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) } self.changedtick = changedtick end - --- @type RendererHighlight[] - local hls = {} - - --- @type { tag:Tag[]; start0: [number, number]; stop0: [number, number] }[] - local tag_infos = {} + --- @type RendererExtmark[] + local extmarks = {} --- @type string[] local lines = Renderer.markup_to_lines { tree = tree, on_tag = function(tag, start0, stop0) - table.insert(tag_infos, { tag = tag, start0 = start0, stop0 = stop0 }) - local function attr_num(nm) - if not tag.attributes[nm] then return end - return tonumber(tag.attributes[nm]) - end - local function attr_bool(nm) - if not tag.attributes[nm] then return end - return tag.attributes[nm] and true or false - end - if tag.name == 'text' then - local group = tag.attributes.hl - if type(group) == 'string' then - table.insert(hls, { group = group, start = start0, stop = stop0 }) + local hl = tag.attributes.hl + if type(hl) == 'string' then + tag.attributes.extmark = tag.attributes.extmark or {} + tag.attributes.extmark.hl_group = hl + end - local local_exists = #vim.tbl_keys(vim.api.nvim_get_hl(self.ns, { name = group })) > 0 - local global_exists = #vim.tbl_keys(vim.api.nvim_get_hl(0, { name = group })) - local exists = local_exists or global_exists + local extmark = tag.attributes.extmark - if not exists or attr_bool 'hl:force' then - vim.api.nvim_set_hl_ns(self.ns) - vim.api.nvim_set_hl(self.ns, group, { - fg = tag.attributes['hl:fg'], - bg = tag.attributes['hl:bg'], - sp = tag.attributes['hl:sp'], - blend = attr_num 'hl:blend', - bold = attr_bool 'hl:bold', - standout = attr_bool 'hl:standout', - underline = attr_bool 'hl:underline', - undercurl = attr_bool 'hl:undercurl', - underdouble = attr_bool 'hl:underdouble', - underdotted = attr_bool 'hl:underdotted', - underdashed = attr_bool 'hl:underdashed', - strikethrough = attr_bool 'hl:strikethrough', - italic = attr_bool 'hl:italic', - reverse = attr_bool 'hl:reverse', - nocombine = attr_bool 'hl:nocombine', - link = tag.attributes['hl:link'], - default = attr_bool 'hl:default', - ctermfg = attr_num 'hl:ctermfg', - ctermbg = attr_num 'hl:ctermbg', - cterm = tag.attributes['hl:cterm'], - force = attr_bool 'hl:force', - }) - end + -- Force creating an extmark if there are key handlers. To accurately + -- sense the bounds of the text, we need an extmark: + if tag.attributes.on_key or tag.attributes.on_typed then extmark = extmark or {} end + + if extmark then + table.insert(extmarks, { + start = start0, + stop = stop0, + opts = extmark, + tag = tag, + }) end end end, } self.old = self.curr - self.curr = { lines = lines, hls = hls } - self.tag_infos = tag_infos + self.curr = { lines = lines, extmarks = extmarks } self:_reconcile() end @@ -236,6 +208,9 @@ function Renderer:_reconcile() local line_changes = utils.levenshtein(self.old.lines, self.curr.lines) self.old = self.curr + -- + -- Step 1: morph the text to the desired state: + -- self:_log { line_changes = line_changes } for _, line_change in ipairs(line_changes) do local lnum0 = line_change.index - 1 @@ -266,53 +241,97 @@ function Renderer:_reconcile() -- No change end end + self.changedtick = vim.b[self.bufnr].changedtick + + -- + -- Step 2: reconcile extmarks: + -- + -- Clear current extmarks: + vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1) + -- Set current extmarks: + for _, extmark in ipairs(self.curr.extmarks) do + extmark.id = vim.api.nvim_buf_set_extmark( + self.bufnr, + self.ns, + extmark.start[1], + extmark.start[2], + vim.tbl_extend('force', { + id = extmark.id, + end_row = extmark.stop[1], + end_col = extmark.stop[2], + }, extmark.opts) + ) + end + + -- + -- Step 3: setup and updated on_key handler: + -- vim.on_key(nil, self.ns) vim.on_key(function(key, typed) -- Discard if not in the current buffer: - if vim.api.nvim_get_current_buf() ~= self.bufnr then return end + if + -- do not capture keys in the wrong buffer: + vim.api.nvim_get_current_buf() ~= self.bufnr + -- do not capture keys in COMMAND mode: + or vim.startswith(vim.api.nvim_get_mode().mode, 'c') + then + return + end -- find the tag with the smallest intersection that contains the cursor: local pos0 = vim.api.nvim_win_get_cursor(0) pos0[1] = pos0[1] - 1 -- make it actually 0-based local cursor_line0, cursor_col0 = unpack(pos0) - -- Find the tag that contains the cursor: If the tag is listening for the - -- key that just fired, call its callback: - for _, tag_info in ipairs(self.tag_infos) do - local tag = tag_info.tag - local start0, stop0 = tag_info.start0, tag_info.stop0 - if - -- line: - start0[1] <= cursor_line0 - and stop0[1] >= cursor_line0 - -- column: - and start0[2] <= cursor_col0 - and stop0[2] > cursor_col0 - then - -- is the tag listening? - if tag.attributes.on_key and type(tag.attributes.on_key[key]) == 'function' then - -- key: - return tag.attributes.on_key[key]() - elseif tag.attributes.on_typed and type(tag.attributes.on_typed[typed]) == 'function' then - -- typed: - return tag.attributes.on_typed[typed]() + -- The cursor (block) occupies **two** extmark spaces: one for it's left + -- edge, and one for it's right. We need to do our own intersection test, + -- because the NeoVim API is over-inclusive in what it returns: + --- @type { id: number; row0: number; col0: number; details: any }[] + local intersecting_extmarks = vim + .iter(vim.api.nvim_buf_get_extmarks(self.bufnr, self.ns, pos0, pos0, { details = true, overlap = true })) + :map(function(ext) + local id, row0, col0, details = unpack(ext) + return { id = id, row0 = row0, col0 = col0, details = details } + end) + :filter(function(ext) + if ext.details.end_row ~= nil and ext.details.end_col ~= nil then + return cursor_line0 >= ext.row0 + and cursor_col0 >= ext.col0 + and cursor_line0 <= ext.details.end_row + and cursor_col0 < ext.details.end_col + else + return true end + end) + :totable() + + -- When we set the extmarks in the step above, we captured the IDs of the + -- created extmarks in self.curr.extmarks, which also has which tag each + -- extmark is associated with. Cross-reference with that list to get a list + -- of tags that we need to fire events for: + local matching_tags = vim + .iter(intersecting_extmarks) + :map(function(ext) + for _, extmark_cache in ipairs(self.curr.extmarks) do + if extmark_cache.id == ext.id then return extmark_cache.tag end + end + end) + :totable() + + -- Check the attributes for each matching_tag and fire events if they are + -- listening: + for _, tag in ipairs(matching_tags) do + -- is the tag listening? + if tag.attributes.on_key and type(tag.attributes.on_key[key]) == 'function' then + -- key: + return tag.attributes.on_key[key]() + elseif tag.attributes.on_typed and type(tag.attributes.on_typed[typed]) == 'function' then + -- typed: + return tag.attributes.on_typed[typed]() end end end, self.ns) - - -- vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, self.curr.lines) - vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1) - for _, hl in ipairs(self.curr.hls) do - (vim.hl or vim.highlight).range(self.bufnr, self.ns, hl.group, hl.start, hl.stop, { - inclusive = false, - priority = (vim.hl or vim.highlight).priorities.user, - regtype = 'v', - }) - end - - self.changedtick = vim.b[self.bufnr].changedtick end return Renderer diff --git a/spec/renderer_spec.lua b/spec/renderer_spec.lua index 74e7769..6ad35d8 100644 --- a/spec/renderer_spec.lua +++ b/spec/renderer_spec.lua @@ -1,59 +1,7 @@ local Renderer = require 'u.renderer' ---- @param markup string -local function parse(markup) - -- call private method: - return (Renderer --[[@as any]])._parse_markup(markup) -end - describe('Renderer', function() - it('_parse_markup: empty string', function() - local nodes = parse [[]] - assert.are.same({}, nodes) - end) - - it('_parse_markup: only string', function() - local nodes = parse [[The quick brown fox jumps over the lazy dog.]] - assert.are.same({ - { kind = 'text', value = 'The quick brown fox jumps over the lazy dog.' }, - }, nodes) - end) - - it('_parse_markup: <', function() - local nodes = parse [[<t value="bleh" />]] - assert.are.same({ - { kind = 'text', value = '' }, - }, nodes) - end) - - it('_parse_markup: empty tag', function() - local nodes = parse [[]] - assert.are.same({ { kind = 'tag', name = '', attributes = {} } }, nodes) - end) - - it('_parse_markup: tag', function() - local nodes = parse [[]] - assert.are.same({ - { - kind = 'tag', - name = 't', - attributes = { - value = 'Hello', - }, - }, - }, nodes) - end) - - it('_parse_markup: attributes with quotes', function() - local nodes = parse [[]] - assert.are.same({ - { - kind = 'tag', - name = 't', - attributes = { - value = 'Hello "there"', - }, - }, - }, nodes) + it('markup_to_string', function() + assert.are.equal(true, false) -- TODO end) end)