diff --git a/Makefile b/Makefile index 0c4a074..4e5b84f 100644 --- a/Makefile +++ b/Makefile @@ -19,3 +19,6 @@ test: @echo "## Generating coverage report" @luacov @awk '/^Summary$$/{flag=1;next} flag{print}' luacov.report.out + +watch: + @watchexec -c -e lua make diff --git a/examples/counter.lua b/examples/counter.lua index fa303c7..ebc3f73 100644 --- a/examples/counter.lua +++ b/examples/counter.lua @@ -22,10 +22,29 @@ tracker.create_effect(function() -- constructed with `h(...)` calls). To help organize the markup, text and -- tags can be nested in tables at any depth. Line breaks must be specified -- manually, with '\n'. + local text_ref = ui_buf.renderer:create_ref() ui_buf:render { 'Reactive Counter Example\n', '========================\n\n', + { + 'Text field: ', + h('text', { hl = 'DiffAdd', ref = text_ref }, { '[]' }), + '\n', + h('text', { + hl = 'DiffAdd', + nmap = { + [''] = function() + vim.notify(text_ref:text()) + return '' + end, + }, + }, ' Submit '), + }, + + '\n', + '\n', + { 'Counter: ', tostring(count), '\n' }, '\n', diff --git a/lua/u/buffer.lua b/lua/u/buffer.lua index 0897a4f..410d1d3 100644 --- a/lua/u/buffer.lua +++ b/lua/u/buffer.lua @@ -5,7 +5,7 @@ local Renderer = require('u.renderer').Renderer --- @field bufnr number --- @field b vim.var_accessor --- @field bo vim.bo ---- @field private renderer u.Renderer +--- @field renderer u.renderer.Renderer local Buffer = {} Buffer.__index = Buffer diff --git a/lua/u/range.lua b/lua/u/range.lua index 69406dd..5a96b51 100644 --- a/lua/u/range.lua +++ b/lua/u/range.lua @@ -91,14 +91,12 @@ function Range.from_marks(lpos, rpos) end --- @param bufnr number ---- @param id number -function Range.from_extmark(bufnr, id) - local mode = 'v' - +--- @param extmark vim.api.keyset.get_extmark_item_by_id +function Range.from_extmark(bufnr, extmark) ---@type integer, integer, vim.api.keyset.extmark_details | nil - local start_row0, start_col0, details = - unpack(vim.api.nvim_buf_get_extmark_by_id(bufnr, NS, id, { details = true })) + local start_row0, start_col0, details = unpack(extmark) + local mode = 'v' local start = Pos.new(bufnr, start_row0 + 1, start_col0 + 1) local stop = details and Pos.new(bufnr, details.end_row + 1, details.end_col) @@ -651,7 +649,14 @@ end function ExtmarkRange.new(bufnr, id) return setmetatable({ bufnr = bufnr, id = id }, ExtmarkRange) end -function ExtmarkRange:range() return Range.from_extmark(self.bufnr, self.id) end +function ExtmarkRange:range() + return Range.from_extmark( + self.bufnr, + vim.api.nvim_buf_get_extmark_by_id(self.bufnr, NS, self.id, { + details = true, + }) + ) +end function ExtmarkRange:delete() vim.api.nvim_buf_del_extmark(self.bufnr, NS, self.id) end diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua index 1873ecc..25329ef 100644 --- a/lua/u/renderer.lua +++ b/lua/u/renderer.lua @@ -1,12 +1,21 @@ local M = {} local H = {} ---- @alias u.renderer.Tag { kind: 'tag'; name: string, attributes: table, children: u.renderer.Tree } +--- @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 } + +--- @class u.renderer.Tag +--- @field kind 'tag' +--- @field name string +--- @field attributes u.renderer.TagAttributes +--- @field children u.renderer.Tree + --- @alias u.renderer.Node nil | boolean | string | u.renderer.Tag --- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[] -- luacheck: ignore ---- @type table, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: table, children: u.renderer.Tree): u.renderer.Tag> +--- @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 { @@ -24,15 +33,66 @@ M.h = setmetatable({}, { end, }) --- Renderer {{{ ---- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any } +-- TagRef {{{ +--- @class u.renderer.TagRef +--- @field tag? u.renderer.Tag +--- @field renderer u.renderer.Renderer +local TagRef = {} +TagRef.__index = TagRef ---- @class u.Renderer +function TagRef:extmark() + if not self.tag then return {} end + + --- @type u.renderer.RendererExtmark? + local extmark_inf = vim + .iter(self.renderer.curr.extmarks) + --- @param x u.renderer.RendererExtmark + :find(function(x) return x.tag == self.tag end) + if not extmark_inf then return {} end + + local extmark = vim.api.nvim_buf_get_extmark_by_id( + self.renderer.bufnr, + self.renderer.ns, + extmark_inf.id, + { details = true } + ) + return extmark +end + +function TagRef:range() + local extmark = self:extmark() + if not extmark then return nil end + local Range = require 'u.range' + return Range.from_extmark(self.renderer.bufnr, extmark) +end + +function TagRef:lines() + local range = self:range() + if not range then return {} end + return range:lines() +end + +function TagRef:text() + local range = self:range() + if not range then return '' end + return range:text() +end +-- }}} + +-- Renderer {{{ +--- @class u.renderer.RendererExtmark +--- @field id? number +--- @field start [number, number] +--- @field stop [number, number] +--- @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: RendererExtmark[] } ---- @field curr { lines: string[]; extmarks: RendererExtmark[] } +--- @field old { lines: string[]; extmarks: u.renderer.RendererExtmark[] } +--- @field curr { lines: string[]; extmarks: u.renderer.RendererExtmark[] } local Renderer = {} Renderer.__index = Renderer M.Renderer = Renderer @@ -67,6 +127,8 @@ function Renderer.new(bufnr) -- {{{ return self end -- }}} +function Renderer:create_ref() return setmetatable({ renderer = self }, TagRef) end + --- @param opts { --- tree: u.renderer.Tree; --- on_tag?: fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any; @@ -199,7 +261,7 @@ function Renderer:render(tree) -- {{{ self.changedtick = changedtick end - --- @type RendererExtmark[] + --- @type u.renderer.RendererExtmark[] local extmarks = {} --- @type string[] @@ -214,28 +276,36 @@ function Renderer:render(tree) -- {{{ tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or hl end - local extmark = tag.attributes.extmark + local extmark_opts = tag.attributes.extmark -- Set any necessary keymaps: for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do for lhs, _ in pairs(tag.attributes[mode .. 'map'] or {}) do -- Force creating an extmark if there are key handlers. To accurately -- sense the bounds of the text, we need an extmark: - extmark = extmark or {} + extmark_opts = extmark_opts or {} vim.keymap.set( - 'n', + mode, lhs, - function() return self:_expr_map_callback('n', lhs) end, + function() return self:_expr_map_callback(mode, lhs) end, { buffer = self.bufnr, expr = true, replace_keycodes = true } ) end end - if extmark then + -- Check for refs: + if tag.attributes.ref and getmetatable(tag.attributes.ref) == TagRef then + --- @type u.renderer.TagRef + local ref = tag.attributes.ref + ref.tag = tag + extmark_opts = extmark_opts or {} + end + + if extmark_opts then table.insert(extmarks, { start = start0, stop = stop0, - opts = extmark, + opts = extmark_opts, tag = tag, }) end @@ -316,9 +386,10 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{ local tag = pos_info.tag -- is the tag listening? + --- @type u.renderer.TagEventHandler? local f = vim.tbl_get(tag.attributes, mode .. 'map', lhs) if type(f) == 'function' then - local result = f() + local result = f(tag, mode, lhs) if result == '' then -- bubble-up to the next tag, but set cancel to true, in case there are -- no more tags to bubble up to: @@ -339,14 +410,14 @@ end -- }}} --- --- @private (private for now) --- @param pos0 [number; number] ---- @return { extmark: RendererExtmark; tag: u.renderer.Tag; }[] +--- @return { extmark: u.renderer.RendererExtmark; tag: u.renderer.Tag; }[] function Renderer:get_pos_infos(pos0) -- {{{ local cursor_line0, cursor_col0 = pos0[1], pos0[2] -- 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 RendererExtmark[] + --- @type u.renderer.RendererExtmark[] local intersecting_extmarks = vim .iter( vim.api.nvim_buf_get_extmarks( @@ -357,7 +428,7 @@ function Renderer:get_pos_infos(pos0) -- {{{ { details = true, overlap = true } ) ) - --- @return RendererExtmark + --- @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) @@ -368,7 +439,7 @@ function Renderer:get_pos_infos(pos0) -- {{{ end return { id = id, start = start, stop = stop, opts = details } end) - --- @param ext RendererExtmark + --- @param ext u.renderer.RendererExtmark :filter(function(ext) if ext.stop[1] ~= nil and ext.stop[2] ~= nil then return cursor_line0 >= ext.start[1] @@ -384,8 +455,8 @@ function Renderer:get_pos_infos(pos0) -- {{{ -- Sort the tags into smallest (inner) to largest (outer): table.sort( intersecting_extmarks, - --- @param x1 RendererExtmark - --- @param x2 RendererExtmark + --- @param x1 u.renderer.RendererExtmark + --- @param x2 u.renderer.RendererExtmark function(x1, x2) if x1.start[1] == x2.start[1] @@ -407,10 +478,10 @@ function Renderer:get_pos_infos(pos0) -- {{{ -- 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: RendererExtmark; tag: u.renderer.Tag }[] + --- @type { extmark: u.renderer.RendererExtmark; tag: u.renderer.Tag }[] local matching_tags = vim .iter(intersecting_extmarks) - --- @param ext RendererExtmark + --- @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 @@ -423,7 +494,7 @@ end -- }}} -- }}} -- TreeBuilder {{{ ---- @class u.TreeBuilder +--- @class u.renderer.TreeBuilder --- @field private nodes u.renderer.Node[] local TreeBuilder = {} TreeBuilder.__index = TreeBuilder @@ -435,7 +506,7 @@ function TreeBuilder.new() end --- @param nodes u.renderer.Tree ---- @return u.TreeBuilder +--- @return u.renderer.TreeBuilder function TreeBuilder:put(nodes) table.insert(self.nodes, nodes) return self @@ -444,7 +515,7 @@ end --- @param name string --- @param attributes? table --- @param children? u.renderer.Node | u.renderer.Node[] ---- @return u.TreeBuilder +--- @return u.renderer.TreeBuilder function TreeBuilder:put_h(name, attributes, children) local tag = M.h(name, attributes, children) table.insert(self.nodes, tag) @@ -452,7 +523,7 @@ function TreeBuilder:put_h(name, attributes, children) end --- @param fn fun(TreeBuilder): any ---- @return u.TreeBuilder +--- @return u.renderer.TreeBuilder function TreeBuilder:nest(fn) local nested_writer = TreeBuilder.new() fn(nested_writer) diff --git a/lua/u/utils.lua b/lua/u/utils.lua index e60933b..b301d40 100644 --- a/lua/u/utils.lua +++ b/lua/u/utils.lua @@ -4,17 +4,63 @@ local M = {} -- Types -- ---- @alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string } ---- @alias KeyMaps table } --- luacheck: ignore ---- @alias RawCmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any } --- luacheck: ignore ---- @alias CmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any; info: u.Range|nil } +--- @class u.utils.QfItem +--- @field col number +--- @field filename string +--- @field kind string +--- @field lnum number +--- @field text string +--- @class u.utils.RawCmdArgs +--- @field args string +--- @field bang boolean +--- @field count number +--- @field fargs string[] +--- @field line1 number +--- @field line2 number +--- @field mods string +--- @field name string +--- @field range 0|1|2 +--- @field reg string +--- @field smods any + +--- @class u.utils.CmdArgs: u.utils.RawCmdArgs +--- @field info u.Range|nil + +--- @class u.utils.UcmdArgs +--- @field nargs? 0|1|'*'|'?'|'+' +--- @field range? boolean|'%'|number +--- @field count? boolean|number +--- @field addr? string +--- @field completion? string +--- @field force? boolean +--- @field preview? fun(opts: u.utils.UcmdArgs, ns: integer, buf: integer):0|1|2 + +-- +-- Functions +-- + +--- Debug utility that prints a value and returns it unchanged. +--- Useful for debugging in the middle of expressions or function chains. +--- --- @generic T ---- @param x `T` ---- @param message? string ---- @return T +--- @param x `T` The value to debug print +--- @param message? string Optional message to print alongside the value +--- @return T The original value, unchanged +--- +--- @usage +--- ```lua +--- -- Debug a value in the middle of a chain: +--- local result = some_function() +--- :map(utils.dbg) -- prints the intermediate value +--- :filter(predicate) +--- +--- -- Debug with a custom message: +--- local config = utils.dbg(get_config(), "Current config:") +--- +--- -- Debug return values: +--- return utils.dbg(calculate_result(), "Final result") +--- ``` function M.dbg(x, message) local t = {} if message ~= nil then table.insert(t, message) end @@ -23,22 +69,37 @@ function M.dbg(x, message) return x end ---- A utility for creating user commands that also pre-computes useful information ---- and attaches it to the arguments. +--- Creates a user command with enhanced argument processing. +--- Automatically computes range information and attaches it as `args.info`. --- +--- @param name string The command name (without the leading colon) +--- @param cmd string | fun(args: u.utils.CmdArgs): any Command implementation +--- @param opts? u.utils.UcmdArgs Command options (nargs, range, etc.) +--- +--- @usage --- ```lua ---- -- Example: ---- ucmd('MyCmd', function(args) ---- -- print the visually selected text: +--- -- Create a command that works with visual selections: +--- utils.ucmd('MyCmd', function(args) +--- -- Print the visually selected text: --- vim.print(args.info:text()) ---- -- or get the vtext as an array of lines: +--- -- Or get the selection as an array of lines: --- vim.print(args.info:lines()) --- end, { nargs = '*', range = true }) +--- +--- -- Create a command that processes the current line: +--- utils.ucmd('ProcessLine', function(args) +--- local line_text = args.info:text() +--- -- Process the line... +--- end, { range = '%' }) +--- +--- -- Create a command with arguments: +--- utils.ucmd('SearchReplace', function(args) +--- local pattern, replacement = args.fargs[1], args.fargs[2] +--- local text = args.info:text() +--- -- Perform search and replace... +--- end, { nargs = 2, range = true }) --- ``` ---- @param name string ---- @param cmd string | fun(args: CmdArgs): any -- luacheck: ignore ---- @param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string } function M.ucmd(name, cmd, opts) local Range = require 'u.range' @@ -50,20 +111,81 @@ function M.ucmd(name, cmd, opts) return cmd(args) end end - vim.api.nvim_create_user_command(name, cmd2, opts or {}) + vim.api.nvim_create_user_command(name, cmd2, opts or {} --[[@as any]]) end ---- @param current_args RawCmdArgs -function M.create_forward_cmd_args(current_args) - local args = { args = current_args.fargs } - if current_args.range == 1 then - args.range = { current_args.line1 } - elseif current_args.range == 2 then - args.range = { current_args.line1, current_args.line2 } - end +--- Creates command arguments for delegating from one command to another. +--- Preserves all relevant context (range, modifiers, bang, etc.) when +--- implementing a derived command in terms of a base command. +--- +--- @param current_args vim.api.keyset.create_user_command.command_args|u.utils.RawCmdArgs The arguments from the current command +--- @return vim.api.keyset.cmd Arguments suitable for vim.cmd() calls +--- +--- @usage +--- ```lua +--- -- Implement :MyEdit in terms of :edit, preserving all context: +--- utils.ucmd('MyEdit', function(args) +--- local delegated_args = utils.create_delegated_cmd_args(args) +--- -- Add custom logic here... +--- vim.cmd.edit(delegated_args) +--- end, { nargs = '*', range = true, bang = true }) +--- +--- -- Implement :MySubstitute that delegates to :substitute: +--- utils.ucmd('MySubstitute', function(args) +--- -- Pre-process arguments +--- local pattern = preprocess_pattern(args.fargs[1]) +--- +--- local delegated_args = utils.create_delegated_cmd_args(args) +--- delegated_args.args = { pattern, args.fargs[2] } +--- +--- vim.cmd.substitute(delegated_args) +--- end, { nargs = 2, range = true, bang = true }) +--- ``` +function M.create_delegated_cmd_args(current_args) + --- @type vim.api.keyset.cmd + local args = { + range = current_args.range == 1 and { current_args.line1 } + or current_args.range == 2 and { current_args.line1, current_args.line2 } + or nil, + count = current_args.count ~= -1 and current_args.count or nil, + reg = current_args.reg ~= '' and current_args.reg or nil, + bang = current_args.bang or nil, + args = #current_args.fargs > 0 and current_args.fargs or nil, + mods = current_args.smods, + } return args end +--- Gets the current editor dimensions. +--- Useful for positioning floating windows or calculating layout sizes. +--- +--- @return { width: number, height: number } The editor dimensions in columns and lines +--- +--- @usage +--- ```lua +--- -- Center a floating window: +--- local dims = utils.get_editor_dimensions() +--- local win_width = 80 +--- local win_height = 20 +--- local col = math.floor((dims.width - win_width) / 2) +--- local row = math.floor((dims.height - win_height) / 2) +--- +--- vim.api.nvim_open_win(bufnr, true, { +--- relative = 'editor', +--- width = win_width, +--- height = win_height, +--- col = col, +--- row = row, +--- }) +--- +--- -- Check if editor is wide enough for side-by-side layout: +--- local dims = utils.get_editor_dimensions() +--- if dims.width >= 160 then +--- -- Use side-by-side layout +--- else +--- -- Use stacked layout +--- end +--- ``` function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end return M diff --git a/shell.nix b/shell.nix index ccf3e7f..e47a675 100644 --- a/shell.nix +++ b/shell.nix @@ -19,5 +19,6 @@ pkgs.mkShell { pkgs.lua51Packages.nlua pkgs.neovim pkgs.stylua + pkgs.watchexec ]; }