WIP: react-like renderer
Some checks failed
NeoVim tests / code-quality (push) Failing after 1m23s

This commit is contained in:
Jonathan Apodaca 2025-10-08 22:25:16 -06:00
parent 72b6886838
commit 8fa2784bf7

View File

@ -5,7 +5,9 @@ local ENABLE_LOG = false
local function log(...) local function log(...)
if not ENABLE_LOG then return end if not ENABLE_LOG then return end
local f = assert(io.open(vim.fs.joinpath(vim.fn.stdpath 'log', 'u.renderer.log'), 'a+')) local f = assert(
io.open(vim.fs.joinpath((vim.fn.stdpath 'log') --[[@as string]], 'u.renderer.log'), 'a+')
)
f:write(os.date() .. '\t' .. vim.inspect { ... } .. '\n') f:write(os.date() .. '\t' .. vim.inspect { ... } .. '\n')
f:close() f:close()
end end
@ -15,11 +17,23 @@ local H = {}
--- @alias u.renderer.TagEventHandler fun(tag: u.renderer.Tag, mode: string, lhs: string): string --- @alias u.renderer.TagEventHandler fun(tag: u.renderer.Tag, mode: string, lhs: string): string
--- @alias u.renderer.TagAttributes { [string]?: unknown; imap?: table<string, u.renderer.TagEventHandler>; nmap?: table<string, u.renderer.TagEventHandler>; vmap?: table<string, u.renderer.TagEventHandler>; xmap?: table<string, u.renderer.TagEventHandler>; omap?: table<string, u.renderer.TagEventHandler>, on_change?: fun(text: string): unknown } --- @alias u.renderer.TagAttributes { [string]?: unknown, imap?: table<string, u.renderer.TagEventHandler>, nmap?: table<string, u.renderer.TagEventHandler>, vmap?: table<string, u.renderer.TagEventHandler>, xmap?: table<string, u.renderer.TagEventHandler>, omap?: table<string, u.renderer.TagEventHandler>, on_change?: (fun(text: string): unknown), key?: string|number }
--- @generic TProps
--- @generic TState
--- @class u.renderer.FnComponentContext<TProps, TState>
--- @field props TProps
--- @field state u.Signal<TState>
--- @field children u.renderer.Tree
--- @field phase 'mount'|'update'|'unmount'
--- @field private unsubscribe? fun(): any
--- @field private prev_tree u.renderer.Tree
--- @alias u.renderer.FnComponent<TProps, TState> fun(ctx: u.renderer.FnComponentContext<TProps, TState>): u.renderer.Tree
--- @class u.renderer.Tag --- @class u.renderer.Tag
--- @field kind 'tag' --- @field kind 'tag'
--- @field name string --- @field name string | u.renderer.FnComponent<any, any>
--- @field attributes u.renderer.TagAttributes --- @field attributes u.renderer.TagAttributes
--- @field children u.renderer.Tree --- @field children u.renderer.Tree
@ -27,7 +41,6 @@ local H = {}
--- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[] --- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[]
-- luacheck: ignore -- luacheck: ignore
--- @type table<string, fun(attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag>
M.h = setmetatable({}, { M.h = setmetatable({}, {
__call = function(_, name, attributes, children) __call = function(_, name, attributes, children)
return { return {
@ -42,39 +55,77 @@ M.h = setmetatable({}, {
return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children) return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children)
end end
end, end,
}) }) --[[@as table<string, fun(attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag>]]
-- Renderer {{{ -- Renderer {{{
--- @class u.renderer.RendererExtmark --- @class u.renderer.RendererExtmark
--- @field id? number --- @field id? integer
--- @field start [number, number] --- @field start [integer, integer]
--- @field stop [number, number] --- @field stop [integer, integer]
--- @field opts vim.api.keyset.set_extmark --- @field opts vim.api.keyset.set_extmark
--- @field tag u.renderer.Tag --- @field tag u.renderer.Tag
--- @class u.renderer.Renderer --- @class u.renderer.Renderer
--- @field bufnr number --- @field bufnr integer
--- @field ns number --- @field ns integer
--- @field changedtick number --- @field changedtick integer
--- @field old { lines: string[]; extmarks: u.renderer.RendererExtmark[] } --- @field text_content { old: { lines: string[], extmarks: u.renderer.RendererExtmark[] }, curr: { lines: string[], extmarks: u.renderer.RendererExtmark[] } }
--- @field curr { lines: string[]; extmarks: u.renderer.RendererExtmark[] } --- @field component_tree { old: u.renderer.Tree, ctx_by_node: table<u.renderer.Tree, u.renderer.FnComponentContext> }
local Renderer = {} local Renderer = {}
Renderer.__index = Renderer Renderer.__index = Renderer
M.Renderer = Renderer M.Renderer = Renderer
--- @private --- @private
--- @param x any --- @param tree u.renderer.Tree
--- @return boolean --- @param visitors {
function Renderer.is_tag(x) return type(x) == 'table' and x.kind == 'tag' end --- nil_?: (fun(): any),
--- boolean?: (fun(b: boolean): any),
--- string?: (fun(s: string): any),
--- array?: (fun(tags: u.renderer.Node[]): any),
--- tag?: (fun(tag: u.renderer.Tag): any),
--- component?: (fun(component: u.renderer.FnComponent, tag: u.renderer.Tag): any),
--- unknown?: fun(tag: any): any
--- }
function Renderer.tree_match(tree, visitors)-- {{{
local function is_tag(x) return type(x) == 'table' and x.kind == 'tag' end
local function is_tag_arr(x) return type(x) == 'table' and not is_tag(x) end
if tree == nil then
return visitors.nil_ and visitors.nil_() or nil
elseif type(tree) == 'boolean' then
return visitors.boolean and visitors.boolean(tree) or nil
elseif type(tree) == 'string' then
return visitors.string and visitors.string(tree) or nil
elseif is_tag_arr(tree) then
return visitors.array and visitors.array(tree --[[@as any]]) or nil
elseif is_tag(tree) then
local tag = tree --[[@as u.renderer.Tag]]
if type(tag.name) == 'function' then
return visitors.component and visitors.component(tag.name --[[@as function]], tag) or nil
else
return visitors.tag and visitors.tag(tree --[[@as any]]) or nil
end
else
return visitors.unknown and visitors.unknown(tree) or error 'unknown value: not a tag'
end
end-- }}}
--- @private --- @private
--- @param x any --- @param tree u.renderer.Tree
--- @return boolean --- @return 'nil' | 'boolean' | 'string' | 'array' | 'tag' | u.renderer.FnComponent | 'unknown'
function Renderer.is_tag_arr(x) function Renderer.tree_kind(tree) -- {{{
if type(x) ~= 'table' then return false end return Renderer.tree_match(tree, {
return #x == 0 or not Renderer.is_tag(x) nil_ = function() return 'nil' end,
end boolean = function() return 'boolean' end,
--- @param bufnr number|nil string = function() return 'string' end,
array = function() return 'array' end,
tag = function() return 'tag' end,
component = function(c) return c end,
unknown = function() return 'unknown' end,
}) --[[@as any]]
end -- }}}
--- @param bufnr integer|nil
function Renderer.new(bufnr) -- {{{ function Renderer.new(bufnr) -- {{{
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
@ -86,8 +137,15 @@ function Renderer.new(bufnr) -- {{{
bufnr = bufnr, bufnr = bufnr,
ns = vim.b[bufnr]._renderer_ns, ns = vim.b[bufnr]._renderer_ns,
changedtick = 0, changedtick = 0,
old = { lines = {}, extmarks = {} }, text_content = {
curr = { lines = {}, extmarks = {} }, old = { lines = {}, extmarks = {} },
curr = { lines = {}, extmarks = {} },
},
component_tree = {
old = nil,
curr = nil,
ctx_by_node = {},
},
}, Renderer) }, Renderer)
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, { vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, {
@ -99,8 +157,8 @@ function Renderer.new(bufnr) -- {{{
end -- }}} end -- }}}
--- @param opts { --- @param opts {
--- tree: u.renderer.Tree; --- tree: u.renderer.Tree,
--- on_tag?: fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any; --- on_tag?: fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any
--- } --- }
function Renderer.markup_to_lines(opts) -- {{{ function Renderer.markup_to_lines(opts) -- {{{
--- @type string[] --- @type string[]
@ -119,39 +177,31 @@ function Renderer.markup_to_lines(opts) -- {{{
curr_col1 = 1 curr_col1 = 1
end end
--- @param node u.renderer.Node --- @param node u.renderer.Tree
local function visit(node) -- {{{ local function visit(node) -- {{{
if node == nil or type(node) == 'boolean' then return end Renderer.tree_match(node, {
string = function(s_node)
if type(node) == 'string' then local node_lines = vim.split(s_node, '\n')
local node_lines = vim.split(node, '\n') for lnum, s in ipairs(node_lines) do
for lnum, s in ipairs(node_lines) do if lnum > 1 then put_line() end
if lnum > 1 then put_line() end put(s)
put(s) end
end end,
elseif Renderer.is_tag(node) then array = function(ts)
local start0 = { curr_line1 - 1, curr_col1 - 1 } for _, child in ipairs(ts) do
-- visit the children:
if Renderer.is_tag_arr(node.children) then
for _, child in
ipairs(node.children --[[@as u.renderer.Node[] ]])
do
-- newlines are not controlled by array entries, do NOT output a line here:
visit(child) visit(child)
end end
else -- luacheck: ignore end,
visit(node.children) tag = function(t)
end assert(type(t.name) ~= 'function', 'markup_to_lines does not render components')
local stop0 = { curr_line1 - 1, curr_col1 - 1 } local start0 = { curr_line1 - 1, curr_col1 - 1 }
if opts.on_tag then opts.on_tag(node, start0, stop0) end visit(t.children)
elseif Renderer.is_tag_arr(node) then local stop0 = { curr_line1 - 1, curr_col1 - 1 }
for _, child in ipairs(node) do
-- newlines are not controlled by array entries, do NOT output a line here: if opts.on_tag then opts.on_tag(t, start0, stop0) end
visit(child) end,
end })
end
end -- }}} end -- }}}
visit(opts.tree) visit(opts.tree)
@ -159,12 +209,12 @@ function Renderer.markup_to_lines(opts) -- {{{
end -- }}} end -- }}}
--- @param opts { --- @param opts {
--- tree: u.renderer.Tree; --- tree: u.renderer.Tree,
--- format_tag?: fun(tag: u.renderer.Tag): string; --- format_tag?: fun(tag: u.renderer.Tag): string
--- } --- }
function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end
--- @param bufnr number --- @param bufnr integer
--- @param old_lines string[] | nil --- @param old_lines string[] | nil
--- @param new_lines string[] --- @param new_lines string[]
function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{ function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{
@ -226,7 +276,8 @@ end -- }}}
function Renderer:render(tree) -- {{{ function Renderer:render(tree) -- {{{
local changedtick = vim.b[self.bufnr].changedtick local changedtick = vim.b[self.bufnr].changedtick
if changedtick ~= self.changedtick then if changedtick ~= self.changedtick then
self.curr = { lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) } self.text_content.curr =
{ extmarks = {}, lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) }
self.changedtick = changedtick self.changedtick = changedtick
end end
@ -283,8 +334,8 @@ function Renderer:render(tree) -- {{{
end, -- }}} end, -- }}}
} }
self.old = self.curr self.text_content.old = self.text_content.curr
self.curr = { lines = lines, extmarks = extmarks } self.text_content.curr = { lines = lines, extmarks = extmarks }
self:_reconcile() self:_reconcile()
vim.cmd.doautocmd { args = { 'User', 'Renderer:' .. tostring(self.bufnr) .. ':render' } } vim.cmd.doautocmd { args = { 'User', 'Renderer:' .. tostring(self.bufnr) .. ':render' } }
end -- }}} end -- }}}
@ -294,7 +345,7 @@ function Renderer:_reconcile() -- {{{
-- --
-- Step 1: morph the text to the desired state: -- Step 1: morph the text to the desired state:
-- --
Renderer.patch_lines(self.bufnr, self.old.lines, self.curr.lines) Renderer.patch_lines(self.bufnr, self.text_content.old.lines, self.text_content.curr.lines)
self.changedtick = vim.b[self.bufnr].changedtick self.changedtick = vim.b[self.bufnr].changedtick
-- --
@ -309,7 +360,7 @@ function Renderer:_reconcile() -- {{{
vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1) vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1)
-- Set current extmarks: -- Set current extmarks:
for _, extmark in ipairs(self.curr.extmarks) do for _, extmark in ipairs(self.text_content.curr.extmarks) do
extmark.id = vim.api.nvim_buf_set_extmark( extmark.id = vim.api.nvim_buf_set_extmark(
self.bufnr, self.bufnr,
self.ns, self.ns,
@ -330,7 +381,7 @@ function Renderer:_reconcile() -- {{{
) )
end end
self.old = self.curr self.text_content.old = self.text_content.curr
end -- }}} end -- }}}
--- @private --- @private
@ -492,7 +543,7 @@ function Renderer:_debug() -- {{{
), ),
}, },
computed = { computed = {
extmarks = self.curr.extmarks, extmarks = self.text_content.curr.extmarks,
}, },
} }
vim.api.nvim_buf_set_lines(info_bufnr, 0, -1, true, vim.split(vim.inspect(info), '\n')) vim.api.nvim_buf_set_lines(info_bufnr, 0, -1, true, vim.split(vim.inspect(info), '\n'))
@ -534,9 +585,9 @@ end -- }}}
--- (outermost). --- (outermost).
--- ---
--- @private (private for now) --- @private (private for now)
--- @param pos0 [number; number] --- @param pos0 [integer, integer]
--- @param mode string? --- @param mode string?
--- @return { extmark: u.renderer.RendererExtmark; tag: u.renderer.Tag; }[] --- @return { extmark: u.renderer.RendererExtmark, tag: u.renderer.Tag }[]
function Renderer:get_tags_at(pos0, mode) -- {{{ function Renderer:get_tags_at(pos0, mode) -- {{{
local cursor_line0, cursor_col0 = pos0[1], pos0[2] local cursor_line0, cursor_col0 = pos0[1], pos0[2]
if not mode then mode = vim.api.nvim_get_mode().mode end if not mode then mode = vim.api.nvim_get_mode().mode end
@ -637,15 +688,15 @@ function Renderer:get_tags_at(pos0, mode) -- {{{
) )
-- When we set the extmarks in the step above, we captured the IDs of the -- 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 -- created extmarks in self.text_content.curr.extmarks, which also has which tag each
-- extmark is associated with. Cross-reference with that list to get a list -- extmark is associated with. Cross-reference with that list to get a list
-- of tags that we need to fire events for: -- of tags that we need to fire events for:
--- @type { extmark: u.renderer.RendererExtmark; tag: u.renderer.Tag }[] --- @type { extmark: u.renderer.RendererExtmark, tag: u.renderer.Tag }[]
local matching_tags = vim local matching_tags = vim
.iter(intersecting_extmarks) .iter(intersecting_extmarks)
--- @param ext u.renderer.RendererExtmark --- @param ext u.renderer.RendererExtmark
:map(function(ext) :map(function(ext)
for _, extmark_cache in ipairs(self.curr.extmarks) do for _, extmark_cache in ipairs(self.text_content.curr.extmarks) do
if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end
end end
end) end)
@ -656,9 +707,9 @@ end -- }}}
--- @private --- @private
--- @param tag_or_id string | u.renderer.Tag --- @param tag_or_id string | u.renderer.Tag
--- @return { start: [number, number]; stop: [number, number] } | nil --- @return { start: [number, number], stop: [number, number] } | nil
function Renderer:get_tag_bounds(tag_or_id) -- {{{ function Renderer:get_tag_bounds(tag_or_id) -- {{{
for _, x in ipairs(self.curr.extmarks) do for _, x in ipairs(self.text_content.curr.extmarks) do
local pos = { start = x.start, stop = x.stop } local pos = { start = x.start, stop = x.stop }
local does_tag_match = type(tag_or_id) == 'string' and x.tag.attributes.id == tag_or_id local does_tag_match = type(tag_or_id) == 'string' and x.tag.attributes.id == tag_or_id
or x.tag == tag_or_id or x.tag == tag_or_id
@ -666,6 +717,175 @@ function Renderer:get_tag_bounds(tag_or_id) -- {{{
end end
end -- }}} end -- }}}
--- @param tree u.renderer.Tree
function Renderer:mount(tree) -- {{{
local tracker = require 'u.tracker'
local function render()
local H2 = {}
--- @param tree u.renderer.Tree
function H2.unmount(tree)
--- @param tree u.renderer.Tree
local function unmount_one(tree)
Renderer.tree_match(tree, {
array = function(tags)
for _, tag in ipairs(tags) do
unmount_one(tag)
end
end,
tag = function(tag) unmount_one(tag.children) end,
component = function(Component, tag)
--- @type u.renderer.FnComponentContext
local ctx = assert(
self.component_tree.ctx_by_node[tree] --[[@as any]],
'could not find context for node'
)
-- depth-first:
unmount_one(ctx.children)
-- now unmount current:
ctx.phase = 'unmount'
Component(ctx)
local unsubscribe = assert(ctx.unsubscribe, 'unmount: unsubscribe is nil')
unsubscribe()
self.component_tree.ctx_by_node[tree] = nil
end,
})
end
unmount_one(tree)
end
--- @param old_tree u.renderer.Tree
--- @param new_tree u.renderer.Tree
--- @return u.renderer.Tree
function H2.visit_tree(old_tree, new_tree)
local old_tree_kind = Renderer.tree_kind(old_tree)
local new_tree_kind = Renderer.tree_kind(new_tree)
local new_tree_rendered = Renderer.tree_match(new_tree, {
string = function(s) return s end,
boolean = function(b) return b end,
nil_ = function() return nil end,
array = function(new_arr)
return H2.visit_array(old_tree --[[@as any]], new_arr)
end,
tag = function(new_tag)
local old_children = old_tree_kind == new_tree_kind and old_tree.children or nil
return M.h(
new_tag.name,
new_tag.attributes,
H2.visit_tree(old_children --[[@as any]], new_tag.children --[[@as any]])
)
end,
component = function(NewC, new_tag)
local old_ctx = old_tree_kind == new_tree_kind
and self.component_tree.ctx_by_node[old_tree]
or nil
if old_tree_kind == new_tree_kind then self.component_tree.ctx_by_node[old_tree] = nil end
--- @param new_tree u.renderer.Tree
--- @param ctx u.renderer.FnComponentContext
--- @param rendered_component u.renderer.Tree
local function update_ctx_and_recurse(new_tree, ctx, rendered_component)
self.component_tree.ctx_by_node[new_tree] = ctx
-- The rendered component could have other components in its tree:
-- recurse and simplify:
local new_tree_simplified = H2.visit_tree(ctx.prev_tree, rendered_component)
ctx.prev_tree = new_tree_simplified
return new_tree_simplified
end
if old_ctx then
old_ctx.phase = 'update'
old_ctx.props = new_tag.attributes
old_ctx.children = new_tag.children
return update_ctx_and_recurse(new_tree, old_ctx, NewC(old_ctx))
else
local state = tracker.create_signal(nil)
--- @type u.renderer.FnComponentContext
local ctx = {
phase = old_tree and 'update' or 'mount',
props = new_tag.attributes,
children = new_tag.children,
state = state --[[@as any]],
prev_tree = nil,
}
local rendered = NewC(ctx)
-- This is the main difference between this else, and the above if:
-- we subscribe _after_ first render. This makes it convenient for
-- first-renders to initialize the state without firing a
-- re-render:
ctx.unsubscribe = state:subscribe(render)
return update_ctx_and_recurse(new_tree, ctx, rendered)
end
end,
})
if old_tree_kind ~= new_tree_kind then H2.unmount(old_tree) end
return new_tree_rendered
end
--- @param old_arr u.renderer.Node[]
--- @param new_arr u.renderer.Node[]
--- @return u.renderer.Node[]
function H2.visit_array(old_arr, new_arr)
--- @type u.renderer.Node[]
local old_to_unmount = {}
--- @type table<any, u.renderer.Node>
local old_by_key = {}
for _, old in ipairs(old_arr or {}) do
local key = Renderer.tree_match(old, {
tag = function(t) return t.attributes.key end,
component = function(_, t) return t.attributes.key end,
})
if not key then
table.insert(old_to_unmount, old)
else
old_by_key[key] = old
end
end
--- @type u.renderer.Node[]
local resulting_nodes = {}
for _, new in ipairs(new_arr) do
local key = Renderer.tree_match(new, {
tag = function(t) return t.attributes.key end,
component = function(_, t) return t.attributes.key end,
})
local old = nil
if key then
old = old_by_key[key]
if old then old_by_key[key] = nil end
end
table.insert(resulting_nodes, H2.visit_tree(old, new))
end
for _, n in pairs(old_by_key) do
table.insert(old_to_unmount, n)
end
for _, n in pairs(old_to_unmount) do
H2.unmount(n)
end
return resulting_nodes
end
local renderable_tree = H2.visit_tree(self.component_tree.old, tree)
self.component_tree.old = tree
self:render(renderable_tree)
end
-- Kick off initial render:
render()
end -- }}}
-- }}} -- }}}
-- TreeBuilder {{{ -- TreeBuilder {{{
@ -733,12 +953,12 @@ function TreeBuilder:tree() return self.nodes end
-- Levenshtein utility {{{ -- Levenshtein utility {{{
-- luacheck: ignore -- luacheck: ignore
--- @alias u.renderer.LevenshteinChange<T> ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; }) --- @alias u.renderer.LevenshteinChange<T> ({ kind: 'add', item: T, index: integer } | { kind: 'delete', item: T, index: integer } | { kind: 'change', from: T, to: T, index: integer })
--- @private --- @private
--- @generic T --- @generic T
--- @param x `T`[] --- @param x `T`[]
--- @param y T[] --- @param y T[]
--- @param cost? { of_delete?: fun(x: T): number; of_add?: fun(x: T): number; of_change?: fun(x: T, y: T): number; } --- @param cost? { of_delete?: (fun(x: T): number), of_add?: (fun(x: T): number), of_change?: (fun(x: T, y: T): number) }
--- @return u.renderer.LevenshteinChange<T>[] The changes, from last (greatest index) to first (smallest index). --- @return u.renderer.LevenshteinChange<T>[] The changes, from last (greatest index) to first (smallest index).
function H.levenshtein(x, y, cost) function H.levenshtein(x, y, cost)
-- At the moment, this whole `cost` plumbing is not used. Deletes have the -- At the moment, this whole `cost` plumbing is not used. Deletes have the