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..53c44df 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 = tag.attributes.extmark.hl_group or 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,143 @@ 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 an 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)
+ local pos_infos = self:get_pos_infos(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]()
- end
+ -- Check the attributes for each matching_tag and fire events if they are
+ -- listening:
+ for _, pos_info in ipairs(pos_infos) do
+ local tag = pos_info.tag
+
+ -- is the tag listening?
+ if tag.attributes.on_key and type(tag.attributes.on_key[key]) == 'function' then
+ -- key:
+ local result = tag.attributes.on_key[key]()
+ if result == '' then return '' end
+ elseif tag.attributes.on_typed and type(tag.attributes.on_typed[typed]) == 'function' then
+ -- typed:
+ local result = tag.attributes.on_typed[typed]()
+ if result == '' then return '' end
end
end
end, self.ns)
+end
- -- 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
+--- Returns pairs of extmarks and tags associate with said extmarks. The
+--- returned tags/extmarks are sorted smallest (innermost) to largest
+--- (outermost).
+---
+--- @private (private for now)
+--- @param pos0 [number; number]
+--- @return {
+--- extmark: { id: number; line0: number; col0: number; details: { end_row?: number; end_col?: number } };
+--- tag: Tag;
+--- }[]
+function Renderer:get_pos_infos(pos0)
+ local cursor_line0, cursor_col0 = pos0[1], pos0[2]
- self.changedtick = vim.b[self.bufnr].changedtick
+ -- 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; line0: 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, line0, col0, details = unpack(ext)
+ return { id = id, line0 = line0, 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.line0
+ 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()
+
+ -- Sort the tags into smallest (inner) to largest (outer):
+ table.sort(
+ intersecting_extmarks,
+ --- @param x1 { id: number; line0: number; col0: number; details: { end_row?: number; end_col?: number } }
+ --- @param x2 { id: number; line0: number; col0: number; details: { end_row?: number; end_col?: number } }
+ function(x1, x2)
+ if
+ x1.line0 == x2.line0
+ and x1.col0 == x2.col0
+ and x1.details.end_row == x2.details.end_row
+ and x1.details.end_col == x2.details.end_col
+ then
+ return x1.id < x2.id
+ end
+
+ -- We only care about extmarks that are regions:
+ if x1.details.end_row == nil or x2.details.end_row == nil then return x1.id < x2.id end
+
+ return x1.line0 >= x2.line0
+ and x1.col0 >= x2.col0
+ and x1.details.end_row <= x2.details.end_row
+ and x1.details.end_row <= x2.details.end_row
+ end
+ )
+
+ -- 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:
+ --- @type { extmark: { id: number; line0: number; col0: number; details: { end_row?: number; end_col?: number } }; tag: Tag }[]
+ 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 = ext, tag = extmark_cache.tag } end
+ end
+ end)
+ :totable()
+
+ return matching_tags
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)