restructure tree visit algorithm
Some checks failed
NeoVim tests / code-quality (push) Failing after 1m20s

This commit is contained in:
Jonathan Apodaca 2025-10-11 09:03:49 -06:00
parent fdb2a49638
commit 8a14b6b824

View File

@ -1,15 +1,25 @@
---@diagnostic disable: redefined-local
function _G.URendererOpFuncSwallow() end
local ENABLE_LOG = false
--- Comma-separated list of regex patterns for debug logging. Examples:
--- "render,extmark" or ".*" or "get_tags_at,_on_text_changed" or
--- "^_on_.*,reconcile$"
local DEBUG = vim.env.DEBUG or ''
local function log(...)
if not ENABLE_LOG then return end
--- @param name string
local function log(name, ...)
if DEBUG == '' then return end
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:close()
for pattern in DEBUG:gmatch '[^,]+' do
if name:match(vim.trim(pattern)) then
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 { name, ... } .. '\n')
f:close()
return
end
end
end
local M = {}
@ -22,12 +32,33 @@ local H = {}
--- @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
--- @field props TProps
--- @field state? TState
--- @field children u.renderer.Tree
--- @field private on_change? fun(): any
--- @field private prev_rendered_children? u.renderer.Tree
local FnComponentContext = {}
M.FnComponentContext = FnComponentContext
FnComponentContext.__index = FnComponentContext
--- @param props TProps
--- @param state? TState
--- @param children u.renderer.Tree
function FnComponentContext.new(props, state, children)
return setmetatable({
phase = 'mount',
props = props,
state = state,
children = children,
}, FnComponentContext)
end
--- @param new_state TState
function FnComponentContext:update(new_state)
self.state = new_state
if self.on_change then vim.schedule(function() self.on_change() end) end
end
--- @alias u.renderer.FnComponent<TProps, TState> fun(ctx: u.renderer.FnComponentContext<TProps, TState>): u.renderer.Tree
@ -36,6 +67,7 @@ local H = {}
--- @field name string | u.renderer.FnComponent<any, any>
--- @field attributes u.renderer.TagAttributes
--- @field children u.renderer.Tree
--- @field private ctx? u.renderer.FnComponentContext
--- @alias u.renderer.Node nil | boolean | string | u.renderer.Tag
--- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[]
@ -70,7 +102,7 @@ M.h = setmetatable({}, {
--- @field ns integer
--- @field changedtick integer
--- @field text_content { old: { lines: string[], extmarks: u.renderer.RendererExtmark[] }, curr: { lines: string[], extmarks: u.renderer.RendererExtmark[] } }
--- @field component_tree { old: u.renderer.Tree, ctx_by_node: table<u.renderer.Tree, u.renderer.FnComponentContext> }
--- @field component_tree { old: u.renderer.Tree }
local Renderer = {}
Renderer.__index = Renderer
M.Renderer = Renderer
@ -391,9 +423,9 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
-- 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
log('_expr_map_callback: pos0:', pos0)
log('_expr_map_callback', 'pos0:', pos0)
local pos_infos = self:get_tags_at(pos0)
log('_expr_map_callback: pos_infos:', pos_infos)
log('_expr_map_callback', 'pos_infos:', pos_infos)
if #pos_infos == 0 then return lhs end
@ -443,7 +475,7 @@ function Renderer:_on_text_changed() -- {{{
--- @type integer, integer, vim.api.keyset.extmark_details
local start_row0, start_col0, details = unpack(extmark)
local end_row0, end_col0 = details.end_row, details.end_col
log('_on_text_changed: fetched current extmark for pos_info', {
log('_on_text_changed', 'fetched current extmark for pos_info', {
pos_info = pos_info,
curr_extmark = {
start_row0 = start_row0,
@ -472,7 +504,7 @@ function Renderer:_on_text_changed() -- {{{
or ''
end_col0 = last_line:len()
end
log('_on_text_changed: after position correction', {
log('_on_text_changed', 'after position correction', {
curr_extmark = {
start_row0 = start_row0,
start_col0 = start_col0,
@ -490,7 +522,7 @@ function Renderer:_on_text_changed() -- {{{
local pos2 = { self.bufnr, end_row0 + 1, end_col0 }
local ok, lines = pcall(vim.fn.getregion, pos1, pos2, { type = 'v' })
if not ok then
log('_on_text_changed: getregion: invalid-pos ', {
log('_on_text_changed', 'getregion: invalid-pos ', {
{ pos1, pos2 },
})
vim.api.nvim_echo({
@ -601,7 +633,8 @@ function Renderer:get_tags_at(pos0, mode) -- {{{
{ details = true, overlap = true }
)
log(
'get_tags_at: context:',
'get_tags_at',
'context:',
{ pos0 = pos0, mode = mode, raw_overlapping_extmarks = raw_overlapping_extmarks }
)
@ -719,43 +752,42 @@ 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)
log('Render.mount.umount', { tree = tree })
--- @param tree u.renderer.Tree
local function unmount_one(tree)
local function visit(tree)
Renderer.tree_match(tree, {
array = function(tags)
log('Render.mount.umount.array', { tags = tags })
for _, tag in ipairs(tags) do
unmount_one(tag)
visit(tag)
end
end,
tag = function(tag) unmount_one(tag.children) end,
tag = function(tag)
log('Render.mount.umount.tag', { tag = tag })
visit(tag.children)
end,
component = function(Component, tag)
log('Render.mount.umount.component', { Component = Component, tag = tag })
--- @type u.renderer.FnComponentContext
local ctx = assert(
self.component_tree.ctx_by_node[tree] --[[@as any]],
'could not find context for node'
)
local ctx = assert(tag.ctx, 'could not find context for node')
-- depth-first:
unmount_one(ctx.children)
visit(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
ctx.on_change = nil
end,
})
end
unmount_one(tree)
visit(tree)
end
--- @param old_tree u.renderer.Tree
@ -764,6 +796,12 @@ function Renderer:mount(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)
log('Renderer.mount.visit_tree', {
old_tree_kind = old_tree_kind,
old_tree = old_tree,
new_tree_kind = new_tree_kind,
new_tree = new_tree,
})
local new_tree_rendered = Renderer.tree_match(new_tree, {
string = function(s) return s end,
@ -774,6 +812,7 @@ function Renderer:mount(tree) -- {{{
return H2.visit_array(old_tree --[[@as any]], new_arr)
end,
tag = function(new_tag)
log('Renderer.mount.visit_tree.tag', { new_tag = new_tag })
local old_children = old_tree_kind == new_tree_kind and old_tree.children or nil
return M.h(
new_tag.name,
@ -783,47 +822,34 @@ function Renderer:mount(tree) -- {{{
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
--- @type { tag: u.renderer.Tag, ctx?: u.renderer.FnComponentContext } | nil
local old_component_info = Renderer.tree_match(old_tree, { component = function(_, t) return { tag = t, ctx = t.ctx } end })
local old_tag = old_component_info and old_component_info.tag or nil
local ctx = old_component_info and old_component_info.ctx or nil
--- @param ctx u.renderer.FnComponentContext
--- @param rendered_component u.renderer.Tree
local function update_ctx_and_recurse(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 =
M.h('text', ctx.props, H2.visit_tree(ctx.prev_tree, rendered_component))
ctx.prev_tree = rendered_component
return new_tree_simplified
end
-- TODO: if the left doesn't match, do we unmount here, or leave that for the later code?
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(old_ctx, NewC(old_ctx))
else
local state = tracker.create_signal(nil)
log('Renderer.mount.visit_tree.component', {
NewC = NewC,
new_tag = new_tag,
old_tag = old_tag,
})
if not ctx then
--- @type u.renderer.FnComponentContext
local ctx = {
phase = '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(ctx, rendered)
ctx = FnComponentContext.new(new_tag.attributes, nil, new_tag.children)
else
ctx.phase = 'update'
end
ctx.props = new_tag.attributes
ctx.children = new_tag.children
ctx.on_change = render
new_tag.ctx = ctx
local NewC_rendered_children = NewC(ctx)
local result = H2.visit_tree(ctx.prev_rendered_children, NewC_rendered_children)
ctx.prev_rendered_children = NewC_rendered_children
return result
end,
})
@ -836,7 +862,7 @@ function Renderer:mount(tree) -- {{{
--- @param new_arr u.renderer.Node[]
--- @return u.renderer.Node[]
function H2.visit_array(old_arr, new_arr)
--- @type table<any, u.renderer.Node>
--- @type table<any, u.renderer.Node | nil>
local old_by_key = {}
--- @param tree u.renderer.Tree
@ -871,6 +897,12 @@ function Renderer:mount(tree) -- {{{
table.insert(resulting_nodes, H2.visit_tree(old, new))
end
log('Renderer.mount.visit_array', {
old_arr = old_arr,
new_arr = new_arr,
resulting_nodes = resulting_nodes,
})
-- Unmount whatever is left in the "old" map:
for _, n in pairs(old_by_key) do
H2.unmount(n)