diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua index 73abb8c..3a9a13d 100644 --- a/lua/u/renderer.lua +++ b/lua/u/renderer.lua @@ -1,13 +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', '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 = {} @@ -15,19 +27,52 @@ local H = {} --- @alias u.renderer.TagEventHandler fun(tag: u.renderer.Tag, mode: string, lhs: string): string ---- @alias u.renderer.TagAttributes { [string]?: unknown; imap?: table; nmap?: table; vmap?: table; xmap?: table; omap?: table, on_change?: fun(text: string): unknown } +--- @alias u.renderer.TagAttributes { [string]?: unknown, imap?: table, nmap?: table, vmap?: table, xmap?: table, omap?: table, on_change?: (fun(text: string): unknown), key?: string|number } + +--- @generic TProps +--- @generic TState +--- @class u.renderer.FnComponentContext +--- @field phase 'mount'|'update'|'unmount' +--- @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 fun(ctx: u.renderer.FnComponentContext): u.renderer.Tree --- @class u.renderer.Tag --- @field kind 'tag' ---- @field name string +--- @field name string | u.renderer.FnComponent --- @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[] -- luacheck: ignore ---- @type table & fun(name: string, attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag> M.h = setmetatable({}, { __call = function(_, name, attributes, children) return { @@ -42,39 +87,77 @@ M.h = setmetatable({}, { return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children) end end, -}) +}) --[[@as table & fun(name: string, attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag>]] -- Renderer {{{ --- @class u.renderer.RendererExtmark ---- @field id? number ---- @field start [number, number] ---- @field stop [number, number] +--- @field id? integer +--- @field start [integer, integer] +--- @field stop [integer, integer] --- @field opts vim.api.keyset.set_extmark --- @field tag u.renderer.Tag --- @class u.renderer.Renderer ---- @field bufnr number ---- @field ns number ---- @field changedtick number ---- @field old { lines: string[]; extmarks: u.renderer.RendererExtmark[] } ---- @field curr { lines: string[]; extmarks: u.renderer.RendererExtmark[] } +--- @field bufnr integer +--- @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 } local Renderer = {} Renderer.__index = Renderer M.Renderer = Renderer --- @private ---- @param x any ---- @return boolean -function Renderer.is_tag(x) return type(x) == 'table' and x.kind == 'tag' end +--- @param tree u.renderer.Tree +--- @param visitors { +--- 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 ---- @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 +--- @param tree u.renderer.Tree +--- @return 'nil' | 'boolean' | 'string' | 'array' | 'tag' | u.renderer.FnComponent | 'unknown' +function Renderer.tree_kind(tree) -- {{{ + return Renderer.tree_match(tree, { + nil_ = function() return 'nil' end, + boolean = function() return 'boolean' end, + 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) -- {{{ if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end @@ -86,8 +169,15 @@ function Renderer.new(bufnr) -- {{{ bufnr = bufnr, ns = vim.b[bufnr]._renderer_ns, changedtick = 0, - old = { lines = {}, extmarks = {} }, - curr = { lines = {}, extmarks = {} }, + text_content = { + old = { lines = {}, extmarks = {} }, + curr = { lines = {}, extmarks = {} }, + }, + component_tree = { + old = nil, + curr = nil, + ctx_by_node = {}, + }, }, Renderer) vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, { @@ -99,8 +189,8 @@ function Renderer.new(bufnr) -- {{{ end -- }}} --- @param opts { ---- tree: u.renderer.Tree; ---- on_tag?: fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any; +--- tree: u.renderer.Tree, +--- on_tag?: fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any --- } function Renderer.markup_to_lines(opts) -- {{{ --- @type string[] @@ -119,39 +209,31 @@ function Renderer.markup_to_lines(opts) -- {{{ curr_col1 = 1 end - --- @param node u.renderer.Node + --- @param node u.renderer.Tree 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 Renderer.is_tag(node) then - local start0 = { curr_line1 - 1, curr_col1 - 1 } - - -- 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: + Renderer.tree_match(node, { + string = function(s_node) + local node_lines = vim.split(s_node, '\n') + for lnum, s in ipairs(node_lines) do + if lnum > 1 then put_line() end + put(s) + end + end, + array = function(ts) + for _, child in ipairs(ts) do visit(child) end - else -- luacheck: ignore - visit(node.children) - end + end, + tag = function(t) + assert(type(t.name) ~= 'function', 'markup_to_lines does not render components') - 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 + local start0 = { curr_line1 - 1, curr_col1 - 1 } + visit(t.children) + local stop0 = { curr_line1 - 1, curr_col1 - 1 } + + if opts.on_tag then opts.on_tag(t, start0, stop0) end + end, + }) end -- }}} visit(opts.tree) @@ -159,12 +241,12 @@ function Renderer.markup_to_lines(opts) -- {{{ end -- }}} --- @param opts { ---- tree: u.renderer.Tree; ---- format_tag?: fun(tag: u.renderer.Tag): string; +--- tree: u.renderer.Tree, +--- format_tag?: fun(tag: u.renderer.Tag): string --- } 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 new_lines string[] function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{ @@ -190,8 +272,10 @@ function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{ end -- Morph the text to the desired state: - local line_changes = - H.levenshtein(old_lines or vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), new_lines) + local line_changes = H.levenshtein { + from = old_lines or vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), + to = new_lines, + } for _, line_change in ipairs(line_changes) do local lnum0 = line_change.index - 1 @@ -200,7 +284,7 @@ function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{ elseif line_change.kind == 'change' then -- Compute inter-line diff, and apply: local col_changes = - H.levenshtein(vim.split(line_change.from, ''), vim.split(line_change.to, '')) + H.levenshtein { from = vim.split(line_change.from, ''), to = vim.split(line_change.to, '') } for _, col_change in ipairs(col_changes) do local cnum0 = col_change.index - 1 @@ -226,7 +310,8 @@ end -- }}} 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) } + self.text_content.curr = + { extmarks = {}, lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) } self.changedtick = changedtick end @@ -283,8 +368,8 @@ function Renderer:render(tree) -- {{{ end, -- }}} } - self.old = self.curr - self.curr = { lines = lines, extmarks = extmarks } + self.text_content.old = self.text_content.curr + self.text_content.curr = { lines = lines, extmarks = extmarks } self:_reconcile() vim.cmd.doautocmd { args = { 'User', 'Renderer:' .. tostring(self.bufnr) .. ':render' } } end -- }}} @@ -294,7 +379,7 @@ function Renderer:_reconcile() -- {{{ -- -- 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 -- @@ -309,7 +394,7 @@ function Renderer:_reconcile() -- {{{ vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1) -- 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( self.bufnr, self.ns, @@ -330,7 +415,7 @@ function Renderer:_reconcile() -- {{{ ) end - self.old = self.curr + self.text_content.old = self.text_content.curr end -- }}} --- @private @@ -340,9 +425,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 @@ -392,7 +477,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, @@ -421,7 +506,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, @@ -439,7 +524,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({ @@ -492,7 +577,7 @@ function Renderer:_debug() -- {{{ ), }, 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')) @@ -534,9 +619,9 @@ end -- }}} --- (outermost). --- --- @private (private for now) ---- @param pos0 [number; number] +--- @param pos0 [integer, integer] --- @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) -- {{{ local cursor_line0, cursor_col0 = pos0[1], pos0[2] if not mode then mode = vim.api.nvim_get_mode().mode end @@ -550,7 +635,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 } ) @@ -560,58 +646,65 @@ function Renderer:get_tags_at(pos0, mode) -- {{{ --- @type u.renderer.RendererExtmark[] local mapped_extmarks = vim .iter(raw_overlapping_extmarks) - --- @return u.renderer.RendererExtmark - :map(function(ext) - --- @type number, number, number, { end_row?: number; end_col?: number }|nil - local id, line0, col0, details = unpack(ext) - local start = { line0, col0 } - local stop = { line0, col0 } - if details and details.end_row ~= nil and details.end_col ~= nil then - stop = { details.end_row, details.end_col } + :map( + --- @return u.renderer.RendererExtmark + function(ext) + --- @type integer, integer, integer, { end_row?: number, end_col?: number }|nil + local id, line0, col0, details = unpack(ext) + local start = { line0, col0 } + local stop = { line0, col0 } + if details and details.end_row ~= nil and details.end_col ~= nil then + stop = { + details.end_row --[[@as integer]], + details.end_col --[[@as integer]], + } + end + return { id = id, start = start, stop = stop, opts = details } end - return { id = id, start = start, stop = stop, opts = details } - end) + ) :totable() local intersecting_extmarks = vim .iter(mapped_extmarks) - --- @param ext u.renderer.RendererExtmark - :filter(function(ext) - if ext.stop[1] ~= nil and ext.stop[2] ~= nil then - -- If we've "ciw" and "collapsed" an extmark onto the cursor, - -- the cursor pos will equal the exmark's start AND end. In this - -- case, we want to include the extmark. - if - cursor_line0 == ext.start[1] - and cursor_col0 == ext.start[2] - and cursor_line0 == ext.stop[1] - and cursor_col0 == ext.stop[2] - then + :filter( + --- @param ext u.renderer.RendererExtmark + function(ext) + if ext.stop[1] ~= nil and ext.stop[2] ~= nil then + -- If we've "ciw" and "collapsed" an extmark onto the cursor, + -- the cursor pos will equal the exmark's start AND end. In this + -- case, we want to include the extmark. + if + cursor_line0 == ext.start[1] + and cursor_col0 == ext.start[2] + and cursor_line0 == ext.stop[1] + and cursor_col0 == ext.stop[2] + then + return true + end + + return + -- START: line check + cursor_line0 >= ext.start[1] + -- START: column check + and (cursor_line0 ~= ext.start[1] or cursor_col0 >= ext.start[2]) + -- STOP: line check + and cursor_line0 <= ext.stop[1] + -- STOP: column check + and ( + cursor_line0 ~= ext.stop[1] + or ( + mode == 'i' + -- In insert mode, the cursor is "thin", so <= to compensate: + and cursor_col0 <= ext.stop[2] + -- In normal mode, the cursor is "wide", so < to compensate: + or cursor_col0 < ext.stop[2] + ) + ) + else return true end - - return - -- START: line check - cursor_line0 >= ext.start[1] - -- START: column check - and (cursor_line0 ~= ext.start[1] or cursor_col0 >= ext.start[2]) - -- STOP: line check - and cursor_line0 <= ext.stop[1] - -- STOP: column check - and ( - cursor_line0 ~= ext.stop[1] - or ( - mode == 'i' - -- In insert mode, the cursor is "thin", so <= to compensate: - and cursor_col0 <= ext.stop[2] - -- In normal mode, the cursor is "wide", so < to compensate: - or cursor_col0 < ext.stop[2] - ) - ) - else - return true end - end) + ) :totable() -- Sort the tags into smallest (inner) to largest (outer): @@ -637,18 +730,20 @@ function Renderer:get_tags_at(pos0, mode) -- {{{ ) -- 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 -- 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 .iter(intersecting_extmarks) - --- @param ext u.renderer.RendererExtmark - :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 + :map( + --- @param ext u.renderer.RendererExtmark + function(ext) + 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 + end end - end) + ) :totable() return matching_tags @@ -656,9 +751,9 @@ end -- }}} --- @private --- @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) -- {{{ - 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 does_tag_match = type(tag_or_id) == 'string' and x.tag.attributes.id == tag_or_id or x.tag == tag_or_id @@ -666,6 +761,189 @@ function Renderer:get_tag_bounds(tag_or_id) -- {{{ end end -- }}} +--- @param tree u.renderer.Tree +function Renderer:mount(tree) -- {{{ + local function rerender() + local H2 = {} + + --- @param tree u.renderer.Tree + function H2.unmount(tree) + log('Render.mount.umount', { tree = tree }) + --- @param tree u.renderer.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 + visit(tag) + end + 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(tag.ctx, 'could not find context for node') + + -- depth-first: + visit(ctx.children) + + -- now unmount current: + ctx.phase = 'unmount' + Component(ctx) + ctx.on_change = nil + end, + }) + end + + visit(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) + 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, + 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) + 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, + new_tag.attributes, + H2.visit_tree(old_children --[[@as any]], new_tag.children --[[@as any]]) + ) + end, + + component = function(NewC, new_tag) + --- @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 + + log('Renderer.mount.visit_tree.component', { + NewC = NewC, + new_tag = new_tag, + old_tag = old_tag, + }) + + if not ctx then + --- @type u.renderer.FnComponentContext + 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 = rerender + + 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, + }) + + 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) + -- We are going to hijack levenshtein in order to compute the + -- difference between elements/components. In this model, we need to + -- "update" all the nodes, so no nodes are equal. We will rely on + -- levenshtein to find the "shortest path" to conforming old => new via + -- the cost.of_change function. That will provide the meat of modeling + -- what effort it will take to morph one element into the new form. + -- What levenshtein gives us for free in this model is also informing + -- us what needs to be added (i.e., "mounted"), what needs to be + -- deleted ("unmounted") and what needs to be changed ("updated"). + local changes = H.levenshtein { + from = old_arr or {}, + to = new_arr or {}, + are_equal = function() return false end, + cost = { + of_change = function(tree1, tree2, tree1_idx, tree2_idx) + --- @return string + local function verbose_tree_kind(tree, idx) + return Renderer.tree_match(tree, { + nil_ = function() return 'nil' end, + string = function() return 'string' end, + boolean = function() return 'boolean' end, + array = function() return 'array' end, + tag = function(tag) + return ('tag-%s-%s'):format(tag.name, tostring(tag.attributes.key or idx)) + end, + component = function(C, tag) + return ('component-%s-%s'):format(C, tostring(tag.attributes.key or idx)) + end, + }) + end + local tree1_inf = verbose_tree_kind(tree1, tree1_idx) + local tree2_inf = verbose_tree_kind(tree2, tree2_idx) + return tree1_inf == tree2_inf and 1 or 2 + end, + }, + } + + --- @type u.renderer.Node[] + local resulting_nodes = {} + for _, change in ipairs(changes) do + local resulting_node + if change.kind == 'add' then + resulting_node = H2.visit_tree(nil, change.item) + elseif change.kind == 'delete' then + H2.visit_tree(change.item, nil) + elseif change.kind == 'change' then + resulting_node = H2.visit_tree(change.from, change.to) + end + if resulting_node then table.insert(resulting_nodes, 1, resulting_node) end + end + + log('Renderer.mount.visit_array', { + old_arr = old_arr, + new_arr = new_arr, + changes = changes, + resulting_nodes = resulting_nodes, + }) + + 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: + rerender() +end -- }}} + -- }}} -- TreeBuilder {{{ @@ -733,14 +1011,21 @@ function TreeBuilder:tree() return self.nodes end -- Levenshtein utility {{{ -- luacheck: ignore ---- @alias u.renderer.LevenshteinChange ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; }) +--- @alias u.renderer.LevenshteinChange ({ kind: 'add', item: T, index: integer } | { kind: 'delete', item: T, index: integer } | { kind: 'change', from: T, to: T, index: integer }) --- @private --- @generic T ---- @param x `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 opts { +--- from: `T`[], +--- to: T[], +--- are_equal?: (fun(x: T, y: T, xidx: integer, yidx: integer): boolean), +--- cost?: { +--- of_delete?: (fun(x: T, idx: integer): number), +--- of_add?: (fun(x: T, idx: integer): number), +--- of_change?: (fun(x: T, y: T, xidx: integer, yidx: integer): number) +--- } +--- } --- @return u.renderer.LevenshteinChange[] The changes, from last (greatest index) to first (smallest index). -function H.levenshtein(x, y, cost) +function H.levenshtein(opts) -- At the moment, this whole `cost` plumbing is not used. Deletes have the -- same cost as Adds or Changes. I can imagine a future, however, where -- fudging with the costs of operations produces a more optimized change-set @@ -750,12 +1035,13 @@ function H.levenshtein(x, y, cost) -- in here even though it's not actively used. Hopefully having this -- callback-based plumbing does not cause too much of a performance hit to -- the renderer. - cost = cost or {} - local cost_of_delete_f = cost.of_delete or function() return 1 end - local cost_of_add_f = cost.of_add or function() return 1 end - local cost_of_change_f = cost.of_change or function() return 1 end + if not opts.are_equal then opts.are_equal = function(x, y) return x == y end end + if not opts.cost then opts.cost = {} end + if not opts.cost.of_add then opts.cost.of_add = function() return 1 end end + if not opts.cost.of_change then opts.cost.of_change = function() return 1 end end + if not opts.cost.of_delete then opts.cost.of_delete = function() return 1 end end - local m, n = #x, #y + local m, n = #opts.from, #opts.to -- Initialize the distance matrix local dp = {} for i = 0, m do @@ -776,12 +1062,12 @@ function H.levenshtein(x, y, cost) -- Compute the Levenshtein distance dynamically for i = 1, m do for j = 1, n do - if x[i] == y[j] then + if opts.are_equal(opts.from[i], opts.to[j], i, j) then dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same else - local cost_delete = dp[i - 1][j] + cost_of_delete_f(x[i]) - local cost_add = dp[i][j - 1] + cost_of_add_f(y[j]) - local cost_change = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j]) + local cost_delete = dp[i - 1][j] + opts.cost.of_delete(opts.from[i], i) + local cost_add = dp[i][j - 1] + opts.cost.of_add(opts.to[j], j) + local cost_change = dp[i - 1][j - 1] + opts.cost.of_change(opts.from[i], opts.to[j], i, j) dp[i][j] = math.min(cost_delete, cost_add, cost_change) end end @@ -806,9 +1092,9 @@ function H.levenshtein(x, y, cost) if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then -- potential change - if x[i] ~= y[j] then + if not opts.are_equal(opts.from[i], opts.to[j]) then --- @type u.renderer.LevenshteinChange - local change = { kind = 'change', from = x[i], index = i, to = y[j] } + local change = { kind = 'change', from = opts.from[i], index = i, to = opts.to[j] } table.insert(changes, change) end i = i - 1 @@ -816,13 +1102,13 @@ function H.levenshtein(x, y, cost) elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then -- addition --- @type u.renderer.LevenshteinChange - local change = { kind = 'add', item = y[j], index = i + 1 } + local change = { kind = 'add', item = opts.to[j], index = i + 1 } table.insert(changes, change) j = j - 1 elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then -- deletion --- @type u.renderer.LevenshteinChange - local change = { kind = 'delete', item = x[i], index = i } + local change = { kind = 'delete', item = opts.from[i], index = i } table.insert(changes, change) i = i - 1 else