diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua index fec7694..9487660 100644 --- a/lua/u/renderer.lua +++ b/lua/u/renderer.lua @@ -1,7 +1,9 @@ local utils = require 'u.utils' -local str = require 'u.utils.string' ---- @alias RendererParsedTag { kind: 'tag'; name: string; attributes: table } +--- @alias Tag { kind: 'tag'; name: string, attributes: table, children: Tree } +--- @alias Node nil | boolean | string | Tag +--- @alias Tree Node | Node[] +local TagMetaTable = {} -------------------------------------------------------------------------------- -- Renderer class @@ -14,16 +16,44 @@ local str = require 'u.utils.string' --- @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] }[] local Renderer = {} Renderer.__index = Renderer +--- function h(name: string, attributes: table, children: Node | Node[]): Node +--- @param name string +--- @param attributes? table +--- @param children? Node | Node[] +--- @return Tag +function Renderer.h(name, attributes, children) + return setmetatable({ + kind = 'tag', + name = name, + attributes = attributes or {}, + children = children, + }, TagMetaTable) +end + +--- @param x any +--- @return boolean +function Renderer.is_tag(x) return type(x) == 'table' and getmetatable(x) == TagMetaTable end + +--- @param x any +--- @return boolean +function Renderer.is_tag_arr(x) + if type(x) ~= 'table' then return false end + return #x == 0 or not Renderer.is_tag(x) +end + --- @param bufnr number|nil 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 + local self = setmetatable({ bufnr = bufnr, - ns = vim.api.nvim_create_namespace '', + ns = vim.b[bufnr]._renderer_ns, changedtick = 0, old = { lines = {}, hls = {} }, curr = { lines = {}, hls = {} }, @@ -32,13 +62,10 @@ function Renderer.new(bufnr) end --- @param opts { ---- markup: string; ---- format_tag: fun(tag: RendererParsedTag): string; ---- on_tag?: fun(tag: RendererParsedTag, start0: [number, number], stop0: [number, number]): any; +--- tree: Tree; +--- on_tag?: fun(tag: Tag, start0: [number, number], stop0: [number, number]): any; --- } function Renderer.markup_to_lines(opts) - local nodes = Renderer._parse_markup(opts.markup) - --- @type string[] local lines = {} @@ -55,49 +82,51 @@ function Renderer.markup_to_lines(opts) curr_col1 = 1 end - for _, node in ipairs(nodes) do - if node.kind == 'text' then - local node_lines = vim.split(node.value, '\n') + --- @param node Node + local function visit(node) + if node == nil or type(node) == 'boolean' then return end + + if type(node) == 'string' then + local node_lines = vim.split(node, '\n') for lnum, s in ipairs(node_lines) do if lnum > 1 then put_line() end put(s) end - elseif node.kind == 'tag' then - local value = opts.format_tag(node) + elseif Renderer.is_tag(node) then local start0 = { curr_line1 - 1, curr_col1 - 1 } - local value_lines = vim.split(value, '\n') - for lnum, value_line in ipairs(value_lines) do - if lnum > 1 then put_line() end - put(value_line) + + -- visit the children: + if Renderer.is_tag_arr(node.children) then + for _, child in ipairs(node.children) do + -- newlines are not controlled by array entries, do NOT output a line here: + visit(child) + end + else + visit(node.children) end + local stop0 = { curr_line1 - 1, curr_col1 - 1 } if opts.on_tag then opts.on_tag(node, start0, stop0) end + elseif Renderer.is_tag_arr(node) then + for _, child in ipairs(node) do + -- newlines are not controlled by array entries, do NOT output a line here: + visit(child) + end end end + visit(opts.tree) return lines end --- @param opts { ---- markup: string; ---- format_tag?: fun(tag: RendererParsedTag): string; +--- tree: string; +--- format_tag?: fun(tag: Tag): string; --- } -function Renderer.markup_to_string(opts) - if not opts.format_tag then - opts.format_tag = function(tag) - if tag.name == 't' then - local value = tag.attributes.value - if type(value) == 'string' then return value end - end +function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end - return '' - end - end - return table.concat(Renderer.markup_to_lines(opts), '\n') -end - ---- @param markup string -function Renderer:render(markup) +--- @param tree Tree +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 = {} } @@ -107,20 +136,15 @@ function Renderer:render(markup) --- @type RendererHighlight[] local hls = {} + --- @type { tag:Tag[]; start0: [number, number]; stop0: [number, number] }[] + local tag_infos = {} + --- @type string[] local lines = Renderer.markup_to_lines { - markup = markup, - - format_tag = function(tag) - if tag.name == 't' then - local value = tag.attributes.value - if type(value) == 'string' then return value end - end - - return '' - end, + 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]) @@ -130,7 +154,7 @@ function Renderer:render(markup) return tag.attributes[nm] and true or false end - if tag.name == 't' then + if tag.name == 'text' then local group = tag.attributes.hl if type(group) == 'string' then table.insert(hls, { group = group, start = start0, stop = stop0 }) @@ -172,6 +196,7 @@ function Renderer:render(markup) self.old = self.curr self.curr = { lines = lines, hls = hls } + self.tag_infos = tag_infos self:_reconcile() end @@ -242,6 +267,41 @@ function Renderer:_reconcile() end end + 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 + + -- 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]() + end + 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 @@ -255,98 +315,4 @@ function Renderer:_reconcile() self.changedtick = vim.b[self.bufnr].changedtick end ---- @private ---- @param markup string e.g., [[Something ]] -function Renderer._parse_markup(markup) - --- @type ({ kind: 'text'; value: string } | RendererParsedTag)[] - local nodes = {} - - --- @type 'text' | 'tag' - local mode = 'text' - local pos = 1 - local watchdog = 0 - - local function skip_whitespace() - local _, new_pos = str.eat_while(markup, pos, str.is_whitespace) - pos = new_pos - end - local function check_infinite_loop() - watchdog = watchdog + 1 - if watchdog > #markup then - vim.print('ERROR', { - num_nodes = #nodes, - last_node = nodes[#nodes], - pos = pos, - len = #markup, - }) - error 'infinite loop' - end - end - - while pos <= #markup do - check_infinite_loop() - - if mode == 'text' then - -- - -- Parse contiguous regions of text - -- - local eaten, new_pos = str.eat_while(markup, pos, function(c) return c ~= '<' end) - if #eaten > 0 then table.insert(nodes, { kind = 'text', value = eaten:gsub('<', '<') }) end - pos = new_pos - - if markup:sub(pos, pos) == '<' then mode = 'tag' end - elseif mode == 'tag' then - -- - -- Parse self-closing tags - -- - if markup:sub(pos, pos) == '<' then pos = pos + 1 end - local tag_name, new_pos = str.eat_while(markup, pos, function(c) return not str.is_whitespace(c) end) - pos = new_pos - - if tag_name == '/>' then - -- empty tag - table.insert(nodes, { kind = 'tag', name = '', attributes = {} }) - else - local node = { kind = 'tag', name = tag_name, attributes = {} } - skip_whitespace() - - while markup:sub(pos, pos + 1) ~= '/>' do - check_infinite_loop() - if pos > #markup then error 'unexpected end of markup' end - - local attr_name - attr_name, new_pos = str.eat_while(markup, pos, function(c) return c ~= '=' and not str.is_whitespace(c) end) - pos = new_pos - - local attr_value = nil - if markup:sub(pos, pos) == '=' then - pos = pos + 1 - if markup:sub(pos, pos) == '"' then - pos = pos + 1 - attr_value, new_pos = str.eat_while(markup, pos, function(c, i, s) - local prev_c = s:sub(i - 1, i - 1) - return c ~= '"' or (prev_c == '\\' and c == '"') - end) - pos = new_pos + 1 -- skip the closing '"' - else - attr_value, new_pos = str.eat_while(markup, pos, function(c) return not str.is_whitespace(c) end) - pos = new_pos - end - end - - node.attributes[attr_name] = (attr_value and attr_value:gsub('\\"', '"')) or true - skip_whitespace() - end - pos = pos + 2 -- skip the '/>' - - table.insert(nodes, node) - end - - mode = 'text' - end - end - - return nodes -end - return Renderer diff --git a/lua/u/utils/string.lua b/lua/u/utils/string.lua deleted file mode 100644 index 947b89b..0000000 --- a/lua/u/utils/string.lua +++ /dev/null @@ -1,29 +0,0 @@ -local M = {} - --------------------------------------------------------------------------------- ---- eat_while --------------------------------------------------------------------------------- - ---- @param s string ---- @param pos number ---- @param predicate fun(c: string, i: number, s: string): boolean -function M.eat_while(s, pos, predicate) - local eaten = '' - local curr = pos - local watchdog = 0 - while curr <= #s do - watchdog = watchdog + 1 - if watchdog > #s then error 'infinite loop' end - - local c = s:sub(curr, curr) - if not predicate(c, curr, s) then break end - eaten = eaten .. c - curr = curr + 1 - end - return eaten, curr -end - ---- @param c string -function M.is_whitespace(c) return c == ' ' or c == '\t' or c == '\n' end - -return M