From b00d8d2fa7f4e59cd03c9304680cde03ebf4f947 Mon Sep 17 00:00:00 2001 From: Jonathan Apodaca Date: Sat, 4 Apr 2026 10:19:23 -0600 Subject: [PATCH] pass 1: get rid of obsolete modules --- examples/counter.lua | 65 --- examples/filetree.lua | 470 ------------------- examples/form.lua | 117 ----- examples/notify.lua | 183 -------- examples/picker.lua | 947 -------------------------------------- examples/surround.lua | 10 +- examples/text-objects.lua | 5 +- lua/u/buffer.lua | 131 ------ lua/u/codewriter.lua | 5 +- lua/u/logger.lua | 73 --- lua/u/renderer.lua | 832 --------------------------------- lua/u/repeat.lua | 3 +- lua/u/tracker.lua | 311 ------------- lua/u/utils.lua | 65 --- mise.toml | 2 +- spec/buffer_spec.lua | 31 -- spec/range_spec.lua | 30 +- spec/renderer_spec.lua | 298 ------------ spec/tracker_spec.lua | 207 --------- u.nvim-0.2.0-1.rockspec | 2 - 20 files changed, 29 insertions(+), 3758 deletions(-) delete mode 100644 examples/counter.lua delete mode 100644 examples/filetree.lua delete mode 100644 examples/form.lua delete mode 100644 examples/notify.lua delete mode 100644 examples/picker.lua delete mode 100644 lua/u/buffer.lua delete mode 100644 lua/u/logger.lua delete mode 100644 lua/u/renderer.lua delete mode 100644 lua/u/tracker.lua delete mode 100644 spec/buffer_spec.lua delete mode 100644 spec/renderer_spec.lua delete mode 100644 spec/tracker_spec.lua diff --git a/examples/counter.lua b/examples/counter.lua deleted file mode 100644 index ced10a9..0000000 --- a/examples/counter.lua +++ /dev/null @@ -1,65 +0,0 @@ -local Buffer = require 'u.buffer' -local tracker = require 'u.tracker' -local h = require('u.renderer').h - --- Create an buffer for the UI -vim.cmd.vnew() -local ui_buf = Buffer.current() -ui_buf:set_tmp_options() - -local s_count = tracker.create_signal(0, 'counter_signal') - --- Effect: Render --- Setup the effect for rendering the UI whenever dependencies are updated -tracker.create_effect(function() - -- Calling `Signal:get()` in an effect registers the given signal as a - -- dependency of the current effect. Whenever that signal (or any other - -- dependency) changes, the effect will rerun. In this particular case, - -- rendering the UI is an effect that depends on one signal. - local count = s_count:get() - - -- Markup is hyperscript, which is just 1) text, and 2) tags (i.e., - -- 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'. - ui_buf:render { - 'Reactive Counter Example\n', - '========================\n\n', - - { 'Counter: ', tostring(count), '\n' }, - - '\n', - - { - h('text', { - hl = 'DiffDelete', - nmap = { - [''] = function() - -- Update the contents of the s_count signal, notifying any - -- dependencies (in this case, the render effect): - s_count:schedule_update(function(n) return n - 1 end) - -- Also equivalent: s_count:schedule_set(s_count:get() - 1) - return '' - end, - }, - }, ' Decrement '), - ' ', - h('text', { - hl = 'DiffAdd', - nmap = { - [''] = function() - -- Update the contents of the s_count signal, notifying any - -- dependencies (in this case, the render effect): - s_count:schedule_update(function(n) return n + 1 end) - -- Also equivalent: s_count:schedule_set(s_count:get() - 1) - return '' - end, - }, - }, ' Increment '), - }, - - '\n', - '\n', - { 'Press on each "button" above to increment/decrement the counter.' }, - } -end) diff --git a/examples/filetree.lua b/examples/filetree.lua deleted file mode 100644 index 7e8b359..0000000 --- a/examples/filetree.lua +++ /dev/null @@ -1,470 +0,0 @@ --------------------------------------------------------------------------------- --- File Tree Viewer Module --- --- Future Enhancements: --- - Consider implementing additional features like searching for files, --- filtering displayed nodes, or adding support for more file types. --- - Improve user experience with customizable UI elements and enhanced --- navigation options. --- - Implement a file watcher to automatically update the file tree when files --- change on the underlying filesystem. --------------------------------------------------------------------------------- - ---- @class u.examples.FsDir ---- @field kind 'dir' ---- @field path string ---- @field expanded boolean ---- @field children u.examples.FsNode[] ---- @alias u.examples.FsFile table ---- @alias u.examples.FsNode u.examples.FsDir | u.examples.FsFile ---- @alias u.examples.ShowOpts { root_path?: string, width?: number, focus_path?: string } - -local Buffer = require 'u.buffer' -local Renderer = require('u.renderer').Renderer -local TreeBuilder = require('u.renderer').TreeBuilder -local h = require('u.renderer').h -local tracker = require 'u.tracker' - -local logger = require('u.logger').Logger.new 'filetree' - -local M = {} -local H = {} - --------------------------------------------------------------------------------- --- Helpers: --------------------------------------------------------------------------------- - ---- Splits the given path into a list of path components. ---- @param path string -function H.split_path(path) - local parts = {} - local curr = path - while #curr > 0 and curr ~= '.' and curr ~= '/' do - table.insert(parts, 1, vim.fs.basename(curr)) - curr = vim.fs.dirname(curr) - end - return parts -end - ---- Normalizes the given path to an absolute path. ---- @param path string -function H.normalize(path) return vim.fs.abspath(vim.fs.normalize(path)) end - ---- Computes the relative path from `base` to `path`. ---- @param path string ---- @param base string -function H.relative(path, base) - path = H.normalize(path) - base = H.normalize(base) - if path:sub(1, #base) == base then path = path:sub(#base + 1) end - if vim.startswith(path, '/') then path = path:sub(2) end - return path -end - ---- @param root_path string ---- @return { tree: u.examples.FsDir, path_to_node: table } -function H.get_tree_inf(root_path) - logger:info { 'get_tree_inf', root_path } - --- @type table - local path_to_node = {} - - --- @type u.examples.FsDir - local tree = { - kind = 'dir', - path = H.normalize(root_path or '.'), - expanded = true, - children = {}, - } - path_to_node[tree.path] = tree - - H.populate_dir_children(tree, path_to_node) - return { tree = tree, path_to_node = path_to_node } -end - ---- @param tree u.examples.FsDir ---- @param path_to_node table -function H.populate_dir_children(tree, path_to_node) - tree.children = {} - - for child_path, kind in vim.iter(vim.fs.dir(tree.path, { depth = 1 })) do - child_path = H.normalize(vim.fs.joinpath(tree.path, child_path)) - local prev_node = path_to_node[child_path] - - if kind == 'directory' then - local new_node = { - kind = 'dir', - path = child_path, - expanded = prev_node and prev_node.expanded or false, - children = prev_node and prev_node.children or {}, - } - path_to_node[new_node.path] = new_node - table.insert(tree.children, new_node) - else - local new_node = { - kind = 'file', - path = child_path, - } - path_to_node[new_node.path] = new_node - table.insert(tree.children, new_node) - end - end - - table.sort(tree.children, function(a, b) - -- directories first: - if a.kind ~= b.kind then return a.kind == 'dir' end - return a.path < b.path - end) -end - ---- @class u.examples.RenderOpts ---- @field bufnr number ---- @field prev_winnr number ---- @field root_path string ---- @field focus_path? string ---- ---- @return { expand: fun(path: string), collapse: fun(path: string) } -local function _render_in_buffer(opts) - local winnr = vim.api.nvim_buf_call( - opts.bufnr, - function() return vim.api.nvim_get_current_win() end - ) - local s_tree_inf = tracker.create_signal(H.get_tree_inf(opts.root_path)) - local s_focused_path = tracker.create_signal(H.normalize(opts.focus_path or opts.root_path)) - - tracker.create_effect(function() - local focused_path = s_focused_path:get() - - s_tree_inf:update(function(tree_inf) - local parts = H.split_path(H.relative(focused_path, tree_inf.tree.path)) - local path_to_node = tree_inf.path_to_node - - --- @param node u.examples.FsDir - --- @param child_names string[] - local function expand_to(node, child_names) - if #child_names == 0 then return end - node.expanded = true - - local next_child_name = table.remove(child_names, 1) - for _, child in ipairs(node.children) do - if child.kind == 'dir' and vim.fs.basename(child.path) == next_child_name then - H.populate_dir_children(child, path_to_node) - expand_to(child, child_names) - end - end - end - expand_to(tree_inf.tree, parts) - return tree_inf - end) - end) - - -- - -- :help watch-file - -- - local watcher = vim.uv.new_fs_event() - if watcher ~= nil then - --- @diagnostic disable-next-line: unused-local - watcher:start(opts.root_path, { recursive = true }, function(_err, fname, _status) - fname = H.normalize(fname) - - local dir_path = vim.fs.dirname(fname) - local dir = s_tree_inf:get().path_to_node[dir_path] - if not dir then return end - - s_tree_inf:schedule_update(function(tree_inf) - H.populate_dir_children(dir, tree_inf.path_to_node) - return tree_inf - end) - end) - end - vim.api.nvim_create_autocmd('WinClosed', { - once = true, - pattern = tostring(winnr), - callback = function() - if watcher == nil then return end - - watcher:stop() - watcher = nil - end, - }) - - local controller = {} - - --- @param path string - function controller.focus_path(path) s_focused_path:set(H.normalize(path)) end - - function controller.refresh() s_tree_inf:set(H.get_tree_inf(opts.root_path)) end - - --- @param path string - function controller.expand(path) - path = H.normalize(path) - local path_to_node = s_tree_inf:get().path_to_node - - local node = path_to_node[path] - if node == nil then return end - - if node.kind == 'dir' then - s_tree_inf:update(function(tree_inf2) - H.populate_dir_children(node, path_to_node) - tree_inf2.path_to_node[node.path].expanded = true - return tree_inf2 - end) - if #node.children == 0 then - s_focused_path:set(node.path) - else - s_focused_path:set(node.children[1].path) - end - else - if node.kind == 'file' then - -- open file: - vim.api.nvim_win_call(opts.prev_winnr, function() vim.cmd.edit(node.path) end) - vim.api.nvim_set_current_win(opts.prev_winnr) - end - end - end - - --- @param path string - function controller.collapse(path) - path = H.normalize(path) - local path_to_node = s_tree_inf:get().path_to_node - - local node = path_to_node[path] - if node == nil then return end - - if node.kind == 'dir' then - if node.expanded then - -- collapse self/node: - s_focused_path:set(node.path) - s_tree_inf:update(function(tree_inf2) - tree_inf2.path_to_node[node.path].expanded = false - return tree_inf2 - end) - else - -- collapse parent: - local parent_dir = path_to_node[vim.fs.dirname(node.path)] - if parent_dir ~= nil then - s_focused_path:set(parent_dir.path) - s_tree_inf:update(function(tree_inf2) - tree_inf2.path_to_node[parent_dir.path].expanded = false - return tree_inf2 - end) - end - end - elseif node.kind == 'file' then - local parent_dir = path_to_node[vim.fs.dirname(node.path)] - if parent_dir ~= nil then - s_focused_path:set(parent_dir.path) - s_tree_inf:update(function(tree_inf2) - tree_inf2.path_to_node[parent_dir.path].expanded = false - return tree_inf2 - end) - end - end - end - - --- @param root_path string - function controller.new(root_path) - vim.ui.input({ - prompt = 'New: ', - completion = 'file', - }, function(input) - if input == nil then return end - local new_path = vim.fs.joinpath(root_path, input) - - if vim.endswith(input, '/') then - -- Create a directory: - vim.fn.mkdir(new_path, input, 'p') - else - -- Create a file: - - -- First, make sure the parent directory exists: - vim.fn.mkdir(vim.fs.dirname(new_path), 'p') - - -- Now create an empty file: - local uv = vim.loop or vim.uv - local fd = uv.fs_open(new_path, 'w', 438) - if fd then uv.fs_write(fd, '') end - end - - controller.refresh() - controller.focus_path(new_path) - end) - end - - --- @param path string - function controller.rename(path) - path = H.normalize(path) - local root_path = vim.fs.dirname(path) - vim.ui.input({ - prompt = 'Rename: ', - default = vim.fs.basename(path), - completion = 'file', - }, function(input) - if input == nil then return end - - local new_path = vim.fs.joinpath(root_path, input); - (vim.loop or vim.uv).fs_rename(path, new_path) - controller.refresh() - controller.focus_path(new_path) - end) - end - - -- - -- Render: - -- - local renderer = Renderer.new(opts.bufnr) - tracker.create_effect(function() - --- @type { tree: u.examples.FsDir, path_to_node: table } - local tree_inf = s_tree_inf:get() - local tree = tree_inf.tree - - --- @type string - local focused_path = s_focused_path:get() - - --- As we render the tree, keep track of what line each node is on, so that - --- we have an easy way to make the cursor jump to each node (i.e., line) - --- at will: - --- @type table - local node_lines = {} - local current_line = 0 - - --- The UI is rendered as a list of hypserscript elements: - local tb = TreeBuilder.new() - - --- Since the filesystem is a recursive tree of nodes, we need to - --- recursively render each node. This function does just that: - --- @param node u.examples.FsNode - --- @param level number - local function render_node(node, level) - local name = vim.fs.basename(node.path) - current_line = current_line + 1 - node_lines[node.path] = current_line - - local nmaps = { - h = function() - vim.schedule(function() controller.collapse(node.path) end) - return '' - end, - l = function() - vim.schedule(function() controller.expand(node.path) end) - return '' - end, - n = function() - vim.schedule( - function() - controller.new(node.kind == 'file' and vim.fs.dirname(node.path) or node.path) - end - ) - return '' - end, - r = function() - vim.schedule(function() controller.rename(node.path) end) - return '' - end, - y = function() - vim.fn.setreg([["]], H.relative(node.path, tree.path)) - return '' - end, - } - - if node.kind == 'dir' then - -- - -- Render a directory node: - -- - local icon = node.expanded and '' or '' - tb:put { - current_line > 1 and '\n', - h( - 'text', - { hl = 'Constant', nmap = nmaps }, - { string.rep(' ', level), icon, ' ', name } - ), - } - if node.expanded then - for _, child in ipairs(node.children) do - render_node(child, level + 1) - end - end - elseif node.kind == 'file' then - tb:put { - current_line > 1 and '\n', - h('text', { nmap = nmaps }, { string.rep(' ', level), '󰈔 ', name }), - } - end - end - render_node(tree, 0) - - -- The following modifies buffer contents, so it needs to be scheduled: - vim.schedule(function() - renderer:render(tb:tree()) - - local cpos = vim.api.nvim_win_get_cursor(winnr) - pcall(vim.api.nvim_win_set_cursor, winnr, { node_lines[focused_path], cpos[2] }) - end) - end, 's:tree') - - return controller -end - --------------------------------------------------------------------------------- --- Public API functions: --------------------------------------------------------------------------------- - ---- @class u.examples.CurrentInf ---- @field bufnr number ---- @field winnr number ---- @field controller table -local current_inf = nil - ---- Show the filetree: ---- @param opts? u.examples.ShowOpts -function M.show(opts) - if current_inf ~= nil then return current_inf.controller end - opts = opts or {} - - local prev_winnr = vim.api.nvim_get_current_win() - - vim.cmd 'vnew' - local buf = Buffer.from_nr(vim.api.nvim_get_current_buf()) - buf:set_tmp_options() - - local winnr = vim.api.nvim_get_current_win() - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('H', true, true, true), 'x', false) - vim.api.nvim_win_set_width(0, opts.width or 30) - vim.api.nvim_create_autocmd('WinClosed', { - once = true, - pattern = tostring(winnr), - callback = M.hide, - }) - - vim.wo[0][0].number = false - vim.wo[0][0].relativenumber = false - - local bufnr = vim.api.nvim_get_current_buf() - - local controller = _render_in_buffer(vim.tbl_extend('force', opts, { - bufnr = bufnr, - prev_winnr = prev_winnr, - root_path = opts.root_path or H.normalize '.', - })) - current_inf = { bufnr = bufnr, winnr = winnr, controller = controller } - return controller -end - ---- Hide the filetree: -function M.hide() - if current_inf == nil then return end - pcall(vim.cmd.bdelete, current_inf.bufnr) - current_inf = nil -end - ---- Toggle the filetree: ---- @param opts? u.examples.ShowOpts -function M.toggle(opts) - if current_inf == nil then - M.show(opts) - else - M.hide() - end -end - -return M diff --git a/examples/form.lua b/examples/form.lua deleted file mode 100644 index 875e128..0000000 --- a/examples/form.lua +++ /dev/null @@ -1,117 +0,0 @@ --- form.lua: --- --- This is a runnable example of a form. Open this file in Neovim, and execute --- `:luafile %` to run it. It will create a new buffer to the side, and render --- an interactive form. Edit the "inputs" between the `[...]` brackets, and --- watch the buffer react immediately to your changes. --- - -local Renderer = require('u.renderer').Renderer -local h = require('u.renderer').h -local tracker = require 'u.tracker' - --- Create a new, temporary, buffer to the side: -vim.cmd.vnew() -vim.bo.buftype = 'nofile' -vim.bo.bufhidden = 'wipe' -vim.bo.buflisted = false -local renderer = Renderer.new() - --- Create two signals: -local s_name = tracker.create_signal 'whoever-you-are' -local s_age = tracker.create_signal 'ideally-a-number' - --- We can create derived information from the signals above. Say we want to do --- some validation on the input for `age`: we can do that with a memo: -local s_age_info = tracker.create_memo(function() - local age_raw = s_age:get() - local age_digits = age_raw:match '^%s*(%d+)%s*$' - local age_n = age_digits and tonumber(age_digits) or nil - return { - type = age_n and 'number' or 'string', - raw = age_raw, - n = age_n, - n1 = age_n and age_n + 1 or nil, - } -end) - --- This is the render effect that depends on the signals created above. This --- will re-run every time one of the signals changes. -tracker.create_effect(function() - local name = s_name:get() - local age = s_age:get() - local age_info = s_age_info:get() - - -- Each time the signals change, we re-render the buffer: - renderer:render { - h.Type({}, '# Form Example'), - '\n\n', - - -- We can also listen for when specific locations in the buffer change, on - -- a tag-by-tag basis. This gives us two-way data-binding between the - -- buffer and the signals. - { - 'Name: ', - h.Structure({ - on_change = function(text) s_name:set(text) end, - }, name), - }, - { - '\nAge: ', - h.Structure({ - on_change = function(text) s_age:set(text) end, - }, age), - }, - - '\n\n', - - -- Show the values of the signals here, too, so that we can see the - -- reactivity in action. If you change the values in the tags above, you - -- can see the changes reflected here immediately. - { 'Hello, "', name, '"!' }, - - -- - -- A more complex example: we can do much more complex rendering, based on - -- the state. For example, if you type different values into the `age` - -- field, you can see not only the displayed information change, but also - -- the color of the highlights in this section will adapt to the type of - -- information that has been detected. - -- - -- If string input is detected, values below are shown in the - -- `String`/`ErrorMsg` highlight groups. - -- - -- If number input is detected, values below are shown in the `Number` - -- highlight group. - -- - -- If a valid number is entered, then this section also displays how old - -- you willl be next year (`n + 1`). - -- - - '\n\n', - h.Type({}, '## Computed Information (derived from `age`)'), - '\n\n', - { - 'Type: ', - h('text', { - hl = age_info.type == 'number' and 'Number' or 'String', - }, age_info.type), - }, - { '\nRaw input: ', h.String({}, '"' .. age_info.raw .. '"') }, - { - '\nCurrent age: ', - age_info.n - -- Show the age: - and h.Number({}, tostring(age_info.n)) - -- Show an error-placeholder if the age is invalid: - or h.ErrorMsg({}, '(?)'), - }, - - -- This part is shown conditionally, i.e., only if the age next year can be - -- computed: - age_info.n1 - and { - '\nAge next year: ', - h.Number({}, tostring(age_info.n1)), - }, - } -end) diff --git a/examples/notify.lua b/examples/notify.lua deleted file mode 100644 index 3e078d1..0000000 --- a/examples/notify.lua +++ /dev/null @@ -1,183 +0,0 @@ -local Renderer = require('u.renderer').Renderer -local TreeBuilder = require('u.renderer').TreeBuilder -local tracker = require 'u.tracker' -local utils = require 'u.utils' - -local TIMEOUT = 4000 -local ICONS = { - [vim.log.levels.TRACE] = { text = '󰃤', group = 'DiagnosticSignOk' }, - [vim.log.levels.DEBUG] = { text = '󰃤', group = 'DiagnosticSignOk' }, - [vim.log.levels.INFO] = { text = '', group = 'DiagnosticSignInfo' }, - [vim.log.levels.WARN] = { text = '', group = 'DiagnosticSignWarn' }, - [vim.log.levels.ERROR] = { text = '', group = 'DiagnosticSignError' }, -} -local DEFAULT_ICON = { text = '', group = 'DiagnosticSignOk' } - -local S_EDITOR_DIMENSIONS = - tracker.create_signal(utils.get_editor_dimensions(), 's:editor_dimensions') -vim.api.nvim_create_autocmd('VimResized', { - callback = function() - local new_dim = utils.get_editor_dimensions() - S_EDITOR_DIMENSIONS:set(new_dim) - end, -}) - ---- @class u.examples.Notification ---- @field kind number ---- @field id number ---- @field text string ---- @field timer uv.uv_timer_t - -local M = {} - ---- @type { win: integer, buf: integer, renderer: u.renderer.Renderer } | nil -local notifs_w - -local s_notifications_raw = tracker.create_signal {} -local s_notifications = s_notifications_raw:debounce(50) - --- Render effect: -tracker.create_effect(function() - --- @type u.examples.Notification[] - local notifs = s_notifications:get() - --- @type { width: integer, height: integer } - local editor_size = S_EDITOR_DIMENSIONS:get() - - if #notifs == 0 then - if notifs_w then - if vim.api.nvim_win_is_valid(notifs_w.win) then vim.api.nvim_win_close(notifs_w.win, true) end - notifs_w = nil - end - return - end - - local avail_width = editor_size.width - local float_width = 40 - local float_height = math.min(#notifs, editor_size.height - 3) - local win_config = { - relative = 'editor', - anchor = 'NE', - row = 0, - col = avail_width, - width = float_width, - height = float_height, - border = 'single', - focusable = false, - zindex = 900, - } - vim.schedule(function() - if not notifs_w or not vim.api.nvim_win_is_valid(notifs_w.win) then - local b = vim.api.nvim_create_buf(false, true) - local w = vim.api.nvim_open_win(b, false, win_config) - vim.wo[w].cursorline = false - vim.wo[w].list = false - vim.wo[w].listchars = '' - vim.wo[w].number = false - vim.wo[w].relativenumber = false - vim.wo[w].wrap = false - notifs_w = { win = w, buf = b, renderer = Renderer.new(b) } - else - vim.api.nvim_win_set_config(notifs_w.win, win_config) - end - - notifs_w.renderer:render(TreeBuilder.new() - :nest(function(tb) - for idx, notif in ipairs(notifs) do - if idx > 1 then tb:put '\n' end - - local notif_icon = ICONS[notif.kind] or DEFAULT_ICON - tb:put_h('text', { hl = notif_icon.group }, notif_icon.text) - tb:put { ' ', notif.text } - end - end) - :tree()) - vim.api.nvim_win_call(notifs_w.win, function() - vim.fn.winrestview { - -- scroll all the way left: - leftcol = 0, - -- set the bottom line to be at the bottom of the window: - topline = vim.api.nvim_buf_line_count(notifs_w.buf) - win_config.height + 1, - } - end) - end) -end) - ---- @param id number -local function _delete_notif(id) - --- @param notifs u.examples.Notification[] - s_notifications_raw:schedule_update(function(notifs) - for i, notif in ipairs(notifs) do - if notif.id == id then - notif.timer:stop() - notif.timer:close() - table.remove(notifs, i) - break - end - end - return notifs - end) -end - -local _orig_notify - ---- @param msg string ---- @param level integer|nil ---- @param opts? { id: number } -function M.notify(msg, level, opts) - if level == nil then level = vim.log.levels.INFO end - - opts = opts or {} - local id = opts.id or math.random(999999999) - - --- @type u.examples.Notification? - local notif = vim.iter(s_notifications_raw:get()):find(function(n) return n.id == id end) - if not notif then - -- Create a new notification (maybe): - if vim.trim(msg) == '' then return id end - if level < vim.log.levels.INFO then return id end - - local timer = assert((vim.uv or vim.loop).new_timer(), 'could not create timer') - timer:start(TIMEOUT, 0, function() _delete_notif(id) end) - notif = { - id = id, - kind = level, - text = msg, - timer = timer, - } - --- @param notifs u.examples.Notification[] - s_notifications_raw:schedule_update(function(notifs) - table.insert(notifs, notif) - return notifs - end) - else - -- Update an existing notification: - s_notifications_raw:schedule_update(function(notifs) - -- We already have a copy-by-reference of the notif we want to modify: - notif.timer:stop() - notif.text = msg - notif.kind = level - notif.timer:start(TIMEOUT, 0, function() _delete_notif(id) end) - - return notifs - end) - end - - return id -end - -local _once_msgs = {} -function M.notify_once(msg, level, opts) - if vim.tbl_contains(_once_msgs, msg) then return false end - table.insert(_once_msgs, msg) - vim.notify(msg, level, opts) - return true -end - -function M.setup() - if _orig_notify == nil then _orig_notify = vim.notify end - - vim.notify = M.notify - vim.notify_once = M.notify_once -end - -return M diff --git a/examples/picker.lua b/examples/picker.lua deleted file mode 100644 index eaadd0c..0000000 --- a/examples/picker.lua +++ /dev/null @@ -1,947 +0,0 @@ -local Buffer = require 'u.buffer' -local utils = require 'u.utils' -local Renderer = require('u.renderer').Renderer -local h = require('u.renderer').h -local TreeBuilder = require('u.renderer').TreeBuilder -local tracker = require 'u.tracker' - -local M = {} - -local S_EDITOR_DIMENSIONS = - tracker.create_signal(utils.get_editor_dimensions(), 's:editor_dimensions') -vim.api.nvim_create_autocmd('VimResized', { - callback = function() - local new_dim = utils.get_editor_dimensions() - S_EDITOR_DIMENSIONS:set(new_dim) - end, -}) - ---- @param low number ---- @param x number ---- @param high number -local function clamp(low, x, high) - x = math.max(low, x) - x = math.min(x, high) - return x -end - ---- @generic T ---- @param arr `T`[] ---- @return T[] -local function shallow_copy_arr(arr) return vim.iter(arr):totable() end - --------------------------------------------------------------------------------- --- BEGIN create_picker --- --- This is the star of the show (in this file, anyway). --- In summary, the outline of this function is: --- 1. Setup signals/memos for computing the picker size, and window positions --- 2. Create the two windows: --- a. The picker input. This is where the filter is typed --- b. The picker list. This is where the items are displayed --- 3. Setup event handlers that respond to user input --- 4. Render the list. After all the prework above, this is probably the --- shortest portion of this function. --------------------------------------------------------------------------------- - ---- @class u.examples.SelectController ---- @field get_items fun(): T[] ---- @field set_items fun(items: T[]) ---- @field set_filter_text fun(filter_text: string) ---- @field get_selected_indices fun(): number[] ---- @field get_selected_items fun(): T[] ---- @field set_selected_indices fun(indices: number[], ephemeral?: boolean) ---- @field close fun() ---- @class u.examples.SelectOpts ---- @field items T[] ---- @field multi? boolean ---- @field format_item? fun(item: T): u.renderer.Tree ---- @field on_finish? fun(items: T[], indices: number[]) ---- @field on_selection_changed? fun(items: T[], indices: number[]) ---- @field mappings? table - ---- @generic T ---- @param opts u.examples.SelectOpts -function M.create_picker(opts) -- {{{ - local is_in_insert_mode = vim.api.nvim_get_mode().mode:sub(1, 1) == 'i' - local stopinsert = not is_in_insert_mode - - if opts.multi == nil then opts.multi = false end - - local H = {} - - --- Runs a function `fn`, and if it fails, cleans up the UI by calling - --- `H.finish` - --- - --- @generic T - --- @param fn fun(): `T` - --- @return T - local function safe_run(fn, ...) - local ok, result_or_error = pcall(fn, ...) - if not ok then - pcall(H.finish, true, result_or_error) - error(result_or_error .. '\n' .. debug.traceback()) - end - return result_or_error - end - - --- Creates a function that safely calls the given function, cleaning up the - --- UI if it ever fails - --- - --- @generic T - --- @param fn `T` - --- @return T - local function safe_wrap(fn) - return function(...) return safe_run(fn, ...) end - end - - -- - -- Compute the positions of the input bar and the list: - -- - - -- Reactively compute the space available for the picker based on the size of - -- the editor - local s_editor_dimensions = S_EDITOR_DIMENSIONS:clone() - local s_picker_space_available = tracker.create_memo(safe_wrap(function() - local editor_dim = s_editor_dimensions:get() - local width = math.floor(editor_dim.width * 0.75) - local height = math.floor(editor_dim.height * 0.75) - local row = math.floor((editor_dim.height - height) / 2) - local col = math.floor((editor_dim.width - width) / 2) - return { width = width, height = height, row = row, col = col } - end)) - - -- Reactively compute the size of the prompt (input) bar - local s_w_input_coords = tracker.create_memo(safe_wrap(function() - local picker_coords = s_picker_space_available:get() - return { - width = picker_coords.width, - height = 1, - row = picker_coords.row, - col = picker_coords.col, - } - end)) - - -- Reactively compute the size of the list view - local s_w_list_coords = tracker.create_memo(safe_wrap(function() - local picker_coords = s_picker_space_available:get() - return { - width = picker_coords.width, - height = picker_coords.height - 3, - row = picker_coords.row + 3, - col = picker_coords.col, - } - end)) - - -- - -- Create resources (i.e., windows): - -- - - local w_input_cfg = { - width = s_w_input_coords:get().width, - height = s_w_input_coords:get().height, - row = s_w_input_coords:get().row, - col = s_w_input_coords:get().col, - relative = 'editor', - focusable = true, - border = vim.o.winborder or 'rounded', - } - local w_input_buf = Buffer.create(false, true) - local w_input = vim.api.nvim_open_win(w_input_buf.bufnr, false, w_input_cfg) - vim.wo[w_input][0].cursorline = false - vim.wo[w_input][0].list = false - vim.wo[w_input][0].number = false - vim.wo[w_input][0].relativenumber = false - - -- The following option is a signal to other plugins like 'cmp' to not mess - -- with this buffer: - vim.bo[w_input_buf.bufnr].buftype = 'prompt' - vim.fn.prompt_setprompt(w_input_buf.bufnr, '') - - vim.api.nvim_set_current_win(w_input) - tracker.create_effect(safe_wrap(function() - -- update window position/size every time the editor is resized: - w_input_cfg = vim.tbl_deep_extend('force', w_input_cfg, s_w_input_coords:get()) - vim.api.nvim_win_set_config(w_input, w_input_cfg) - end)) - - local w_list_cfg = { - width = s_w_list_coords:get().width, - height = s_w_list_coords:get().height, - row = s_w_list_coords:get().row, - col = s_w_list_coords:get().col, - relative = 'editor', - focusable = true, - border = 'rounded', - } - local w_list_buf = Buffer.create(false, true) - local w_list = vim.api.nvim_open_win(w_list_buf.bufnr, false, w_list_cfg) - vim.wo[w_list][0].number = false - vim.wo[w_list][0].relativenumber = false - vim.wo[w_list][0].scrolloff = 0 - tracker.create_effect(safe_wrap(function() - -- update window position/size every time the editor is resized: - w_list_cfg = vim.tbl_deep_extend('force', w_list_cfg, s_w_list_coords:get()) - vim.api.nvim_win_set_config(w_list, w_list_cfg) - end)) - - -- Now that we have created the window with the prompt in it, start insert - -- mode so that the user can type immediately: - vim.cmd.startinsert() - - -- - -- State: - -- - - local s_items_raw = tracker.create_signal(opts.items, 's:items') - local s_items = s_items_raw:debounce(100) - local s_selected_indices = tracker.create_signal({}, 's:selected_indices') - local s_top_offset = tracker.create_signal(0, 's:top_offset') - local s_cursor_index = tracker.create_signal(1, 's:cursor_index') - - local s_filter_text_undebounced = tracker.create_signal('', 's:filter_text') - w_input_buf:autocmd('TextChangedI', { - callback = safe_wrap( - function() s_filter_text_undebounced:set(vim.api.nvim_get_current_line()) end - ), - }) - local s_filter_text = s_filter_text_undebounced:debounce(50) - - -- - -- Derived State: - -- - - local s_formatted_items = tracker.create_memo(function() - local function _format_item(item) - return opts.format_item and opts.format_item(item) or tostring(item) - end - - local items = s_items:get() - return vim - .iter(items) - :map(function(item) return { item = item, formatted = _format_item(item) } end) - :totable() - end) - - -- When the filter text changes, update the filtered items: - local s_filtered_items = tracker.create_memo( - safe_wrap(function() - local formatted_items = s_formatted_items:get() - local filter_text = vim.trim(s_filter_text:get()):lower() - - --- @type string - local filter_pattern - --- @type boolean - local use_plain_pattern - if #formatted_items > 250 and #filter_text <= 3 then - filter_pattern = filter_text - use_plain_pattern = true - elseif #formatted_items > 1000 then - filter_pattern = filter_text - use_plain_pattern = true - else - filter_pattern = '(' - .. vim.iter(vim.split(filter_text, '')):map(function(c) return c .. '.*' end):join '' - .. ')' - use_plain_pattern = false - end - filter_pattern = filter_pattern:lower() - - --- @type table - local formatted_strings = {} - --- @type table - local matches = {} - - local new_filtered_items = vim - .iter(formatted_items) - :enumerate() - :map( - function(i, inf) return { orig_idx = i, item = inf.item, formatted = inf.formatted } end - ) - :filter(function(inf) - if filter_text == '' then return true end - local formatted_as_string = Renderer.markup_to_string({ tree = inf.formatted }):lower() - - formatted_strings[inf.orig_idx] = formatted_as_string - if use_plain_pattern then - local x, y = formatted_as_string:find(filter_pattern, 1, true) - if x ~= nil and y ~= nil then matches[inf.orig_idx] = formatted_as_string:sub(x, y) end - else - matches[inf.orig_idx] = string.match(formatted_as_string, filter_pattern) - end - - return matches[inf.orig_idx] ~= nil - end) - :totable() - - -- Don't sort if there are over 500 items: - if #new_filtered_items <= 500 then - table.sort(new_filtered_items, function(a_inf, b_inf) - local a = formatted_strings[a_inf.orig_idx] - local b = formatted_strings[b_inf.orig_idx] - if a == b then return false end - - local a_match = matches[a_inf.orig_idx] - local b_match = matches[b_inf.orig_idx] - return #a_match < #b_match - end) - end - - s_top_offset:set(0) - s_cursor_index:set(1) - return new_filtered_items - end), - 'e:(filter_text=>filtered_items)' - ) - - -- Visible items, are _just_ the items that fit into the current viewport. - -- This is an optimization so that we are not rendering thousands of lines of - -- items on each state-change. - local s_visible_items = tracker.create_memo( - safe_wrap(function() - return vim - .iter(s_filtered_items:get()) - :enumerate() - :skip(s_top_offset:get()) - :take(s_w_list_coords:get().height) - :map( - function(i, inf) - return { - filtered_idx = i, - orig_idx = inf.orig_idx, - item = inf.item, - formatted = inf.formatted, - } - end - ) - :totable() - end), - 'm:visible_items' - ) - - -- Track selection information: - local s_selection_info = tracker.create_memo( - safe_wrap(function() - local items = s_items:get() - local selected_indices = s_selected_indices:get() - --- @type { orig_idx: number, item: T }[] - local filtered_items = s_filtered_items:get() - local cursor_index = s_cursor_index:get() - local indices = shallow_copy_arr(selected_indices) - if #indices == 0 and #filtered_items > 0 then - indices = { filtered_items[cursor_index].orig_idx } - end - return { - items = vim.iter(indices):map(function(i) return items[i] end):totable(), - indices = indices, - } - end), - 'm:selection_info' - ) - - --- When it is time to close the picker, this is the main cleanup routine - --- that runs in all cases: - --- - --- @param esc? boolean Whether the user pressed or not - --- @param err? any Any error that occurred - function H.finish(esc, err) - -- s_editor_dimensions is the only signal that is cloned from a global, - -- one. It is therefore the only one that needs to be manually disposed. - -- The other ones should get cleaned up by the GC - s_editor_dimensions:dispose() - -- If we happen to have any async state-changes coming down the pipeline, - -- we can say right now that we are done rendering new UI (to avoid - -- "invalid window ID" errors): - H.unsubscribe_render_effect() - -- buftype=prompt buffers are not "temporary", so delete the buffer manually: - vim.api.nvim_buf_delete(w_input_buf.bufnr, { force = true }) - -- The following is not needed, since the buffer is deleted above: - -- vim.api.nvim_win_close(w_input, false) - vim.api.nvim_win_close(w_list, false) - if stopinsert then vim.cmd.stopinsert() end - local inf = s_selection_info:get() - if not err and opts.on_finish then - -- If on_finish opens another picker, the closing of this one can happen - -- in _too_ quick succession, so put a small delay in there. - -- - -- TODO: figure out _why_ this is actually happening, and then a better - -- way to handle this. - vim.defer_fn(function() - if esc then - opts.on_finish({}, {}) - else - opts.on_finish(inf.items, inf.indices) - end - end, 100) - end - end - - -- On selection info changed: - tracker.create_effect( - safe_wrap(function() - local inf = s_selection_info:get() - if opts.on_selection_changed then opts.on_selection_changed(inf.items, inf.indices) end - end), - 'e:selection_changed' - ) - - -- - -- Public API (i.e., `controller`): - -- We will fill in the methods further down, but we need this variable in scope so that it can be - -- closed over by some of the event handlers: - -- - local controller = {} - - -- - -- Events - -- - vim.keymap.set('i', '', function() H.finish(true) end, { buffer = w_input_buf.bufnr }) - - vim.keymap.set('i', '', function() H.finish() end, { buffer = w_input_buf.bufnr }) - - local function action_next_line() - local max_line = #s_filtered_items:get() - local next_cursor_index = clamp(1, s_cursor_index:get() + 1, max_line) - if next_cursor_index - s_top_offset:get() > s_w_list_coords:get().height then - s_top_offset:set(s_top_offset:get() + 1) - end - s_cursor_index:set(next_cursor_index) - end - vim.keymap.set( - 'i', - '', - safe_wrap(action_next_line), - { buffer = w_input_buf.bufnr, desc = 'Picker: next' } - ) - vim.keymap.set( - 'i', - '', - safe_wrap(action_next_line), - { buffer = w_input_buf.bufnr, desc = 'Picker: next' } - ) - - local function action_prev_line() - local max_line = #s_filtered_items:get() - local next_cursor_index = clamp(1, s_cursor_index:get() - 1, max_line) - if next_cursor_index - s_top_offset:get() < 1 then s_top_offset:set(s_top_offset:get() - 1) end - s_cursor_index:set(next_cursor_index) - end - vim.keymap.set( - 'i', - '', - safe_wrap(action_prev_line), - { buffer = w_input_buf.bufnr, desc = 'Picker: previous' } - ) - vim.keymap.set( - 'i', - '', - safe_wrap(action_prev_line), - { buffer = w_input_buf.bufnr, desc = 'Picker: previous' } - ) - - vim.keymap.set( - 'i', - '', - safe_wrap(function() - if not opts.multi then return end - - local index = s_filtered_items:get()[s_cursor_index:get()].orig_idx - if vim.tbl_contains(s_selected_indices:get(), index) then - s_selected_indices:set( - vim.iter(s_selected_indices:get()):filter(function(i) return i ~= index end):totable() - ) - else - local new_selected_indices = shallow_copy_arr(s_selected_indices:get()) - table.insert(new_selected_indices, index) - s_selected_indices:set(new_selected_indices) - end - action_next_line() - end), - { buffer = w_input_buf.bufnr } - ) - - for key, fn in pairs(opts.mappings or {}) do - vim.keymap.set( - 'i', - key, - safe_wrap(function() return fn(controller) end), - { buffer = w_input_buf.bufnr } - ) - end - - -- Render: - H.unsubscribe_render_effect = tracker.create_effect( - safe_wrap(function() - local selected_indices = s_selected_indices:get() - local top_offset = s_top_offset:get() - local cursor_index = s_cursor_index:get() - --- @type { filtered_idx: number, orig_idx: number, item: T, formatted: string }[] - local visible_items = s_visible_items:get() - - -- The above has to run in the execution context for the signaling to work, but - -- the following cannot run in a NeoVim loop-callback: - vim.schedule(function() - w_list_buf:render(TreeBuilder.new() - :nest(function(tb) - for loop_idx, inf in ipairs(visible_items) do - local is_cur_line = inf.filtered_idx == cursor_index - local is_selected = vim.tbl_contains(selected_indices, inf.orig_idx) - - tb:put(loop_idx > 1 and '\n') - tb:put(is_cur_line and h('text', { hl = 'Structure' }, '❯') or ' ') - tb:put(is_selected and h('text', { hl = 'Comment' }, '* ') or ' ') - tb:put(inf.formatted) - end - end) - :tree()) - - -- set the window viewport to have the first line in view: - pcall(vim.api.nvim_win_call, w_list, function() vim.fn.winrestview { topline = 1 } end) - pcall(vim.api.nvim_win_set_cursor, w_list, { cursor_index - top_offset, 0 }) - end) - end), - 'e:render' - ) - - -- - -- Populate the public API: - -- - function controller.get_items() - return safe_run(function() return s_items_raw:get() end) - end - - --- @param items T[] - function controller.set_items(items) - return safe_run(function() s_items_raw:set(items) end) - end - - function controller.set_filter_text(filter_text) - return safe_run(function() - vim.api.nvim_win_call(w_input, function() vim.api.nvim_set_current_line(filter_text) end) - end) - end - - function controller.get_selected_indices() - return safe_run(function() return s_selection_info:get().indices end) - end - - function controller.get_selected_items() - return safe_run(function() return s_selection_info:get().items end) - end - - --- @param indicies number[] - --- @param ephemeral? boolean - function controller.set_selected_indices(indicies, ephemeral) - return safe_run(function() - if ephemeral == nil then ephemeral = false end - - if ephemeral and #indicies == 1 then - local matching_filtered_item_idx, _ = vim.iter(s_filtered_items:get()):enumerate():find( - function(_idx, inf) return inf.orig_idx == indicies[1] end - ) - if matching_filtered_item_idx ~= nil then s_cursor_index:set(indicies[1]) end - else - if not opts.multi then - local err = 'Cannot set multiple selected indices on a single-select picker' - H.finish(true, err) - error(err) - end - s_selected_indices:set(indicies) - end - end) - end - - function controller.close() - return safe_run(function() H.finish(true) end) - end - - return controller --[[@as u.examples.SelectController]] -end -- }}} - --------------------------------------------------------------------------------- --- END create_picker --------------------------------------------------------------------------------- - --------------------------------------------------------------------------------- --- vim.ui.select override --------------------------------------------------------------------------------- - -local ORIGINAL_UI_SELECT -function M.register_ui_select() - ORIGINAL_UI_SELECT = vim.ui.select - --- @generic T - --- @param items `T`[] - --- @param opts { prompt?: string, kind?: any, format_item?: fun(item: T):string } - --- @param cb fun(item: T|nil):any - --- @diagnostic disable-next-line: duplicate-set-field - function vim.ui.select(items, opts, cb) - M.create_picker { - items = items, - format_item = function(item) - local s = opts.format_item and opts.format_item(item) or tostring(item) - s = s:gsub('<', '<') - return s - end, - on_finish = function(sel_items) - if #sel_items == 0 then cb(nil) end - cb(sel_items[#sel_items]) - end, - } - end -end -function M.unregister_ui_select() - if not ORIGINAL_UI_SELECT then return end - - vim.ui.select = ORIGINAL_UI_SELECT - ORIGINAL_UI_SELECT = nil -end - --------------------------------------------------------------------------------- --- Built-in pickers --- 1. files --- 2. buffers --- 3. code-symbols --------------------------------------------------------------------------------- - ---- @param opts? { limit?: number } -function M.files(opts) -- {{{ - opts = opts or {} - opts.limit = opts.limit or 10000 - - local cmd = {} - if vim.fn.executable 'rg' then - cmd = { - 'rg', - '--color=never', - '--files', - '--hidden', - '--follow', - '-g', - '!.git', - '-g', - '!node_modules', - '-g', - '!target', - } - elseif vim.fn.executable 'fd' then - cmd = { - 'fd', - '--color=never', - '--type', - 'f', - '--hidden', - '--follow', - '--exclude', - '.git', - '--exclude', - 'node_modules', - '--exclude', - 'target', - } - elseif vim.fn.executable 'find' then - cmd = { - 'find', - '-type', - 'f', - '-not', - '-path', - "'*/.git/*'", - '-not', - '-path', - "'*/node_modules/*'", - '-not', - '-path', - "'*/target/*'", - '-printf', - "'%P\n'", - } - end - - if #cmd == 0 then - vim.notify('rg/fd/find executable not found: cannot list files', vim.log.levels.ERROR) - return - end - - -- Keep track of the job that will list files independent from the picker. We - -- will stream lines from this process to the picker as they come in: - local job_inf = { id = 0, proc_lines = {}, notified_over_limit = false } - - -- Initially, create the picker with no items: - local picker = M.create_picker { - multi = true, - items = {}, - - --- @params items string[] - on_finish = function(items) - pcall(vim.fn.jobstop, job_inf.id) - - if #items == 0 then return end - if #items == 1 then - vim.cmd.edit(items[1]) - else - -- populate quickfix: - vim.fn.setqflist(vim - .iter(items) - :map( - function(item) - return { - filename = item, - lnum = 1, - col = 1, - } - end - ) - :totable()) - vim.cmd.copen() - end - end, - - mappings = { - [''] = function(sel) - sel.close() - --- @type string[] - local items = sel.get_selected_items() - - -- open in new tab: - for _, item in ipairs(items) do - vim.cmd.tabnew(item) - end - end, - - [''] = function(sel) - sel.close() - --- @type string[] - local items = sel.get_selected_items() - - -- open in vertical split: - for _, item in ipairs(items) do - vim.cmd.vsplit(item) - end - end, - - [''] = function(sel) - sel.close() - --- @type string[] - local items = sel.get_selected_items() - - -- open in horizontal split: - for _, item in ipairs(items) do - vim.cmd.split(item) - end - end, - }, - } - - -- Kick off the process that lists the files. As lines come in, send them to - -- the picker: - job_inf.id = vim.fn.jobstart(cmd, { - --- @param data string[] - on_stdout = vim.schedule_wrap(function(_chanid, data, _name) - local lines = job_inf.proc_lines - local function set_lines_as_items_state() - picker.set_items(vim - .iter(lines) - :enumerate() - :filter(function(idx, item) - -- Filter out an incomplete last line: - local is_last_line = idx == #lines - if is_last_line and item == '' then return false end - return true - end) - :map(function(_, item) return item end) - :totable()) - end - - -- It's just not a good idea to process large lists with Lua. The default - -- limit is 10,000 items, and even crunching through this is iffy on a - -- fast laptop. Show a warning and truncate the list in this case. - if #lines >= opts.limit then - if not job_inf.notified_over_limit then - vim.notify( - 'Picker list is too large (truncating list to ' .. opts.limit .. ' items)', - vim.log.levels.WARN - ) - pcall(vim.fn.jobstop, job_inf.id) - job_inf.notified_over_limit = true - end - return - end - - -- :help channel-lines - - local eof = #data == 1 and data[1] == '' - if eof then set_lines_as_items_state() end - - -- Complete the previous line: - if #lines > 0 then lines[#lines] = lines[#lines] .. table.remove(data, 1) end - - for _, l in ipairs(data) do - table.insert(lines, l) - end - - set_lines_as_items_state() - end), - }) -end -- }}} - -function M.buffers() -- {{{ - local cwd = vim.fn.getcwd() - -- ensure that `cwd` ends with a trailing slash: - if cwd[#cwd] ~= '/' then cwd = cwd .. '/' end - - --- @type { name: string, changed: number, bufnr: number }[] - local bufs = vim.fn.getbufinfo { buflisted = 1 } - - M.create_picker { - multi = true, - items = bufs, - - --- @param item { name: string, changed: number, bufnr: number } - format_item = function(item) - local item_name = item.name - if item_name == '' then item_name = '[No Name]' end - -- trim leading `cwd` from the buffer name: - if item_name:sub(1, #cwd) == cwd then item_name = item_name:sub(#cwd + 1) end - - return TreeBuilder.new():put(item.changed == 1 and '[+] ' or ' '):put(item_name):tree() - end, - - --- @params items { bufnr: number }[] - on_finish = function(items) - if #items == 0 then return end - if #items == 1 then - vim.cmd.buffer(items[1].bufnr) - else - -- populate quickfix: - vim.fn.setqflist(vim - .iter(items) - :map( - function(item) - return { - bufnr = item.bufnr, - filename = item.name, - lnum = 1, - col = 1, - } - end - ) - :totable()) - vim.cmd.copen() - end - end, - - mappings = { - [''] = function(sel) - sel.close() - --- @type { bufnr: number }[] - local items = sel.get_selected_items() - - -- open in new tab: - for _, item in ipairs(items) do - vim.cmd.tabnew() - vim.cmd.buffer(item.bufnr) - end - end, - - [''] = function(sel) - sel.close() - --- @type { bufnr: number }[] - local items = sel.get_selected_items() - - -- open in new vertial split: - for _, item in ipairs(items) do - vim.cmd.vsplit() - vim.cmd.buffer(item.bufnr) - end - end, - - [''] = function(sel) - sel.close() - --- @type { bufnr: number }[] - local items = sel.get_selected_items() - - -- open in horizontal split: - for _, item in ipairs(items) do - vim.cmd.split() - vim.cmd.buffer(item.bufnr) - end - end, - - [''] = function(sel) - local selected_items = sel.get_selected_items() - for _, item in ipairs(selected_items) do - -- delete the buffer - vim.cmd.bdelete(item.bufnr) - end - - sel.set_selected_indices {} - sel.set_items( - vim - .iter(sel.get_items()) - :filter(function(item) return not vim.tbl_contains(selected_items, item) end) - :totable() - ) - end, - }, - } -end -- }}} - -local IS_CODE_SYMBOL_RUNNING = false -function M.lsp_code_symbols() -- {{{ - if IS_CODE_SYMBOL_RUNNING then return end - IS_CODE_SYMBOL_RUNNING = true - - -- Avoid callback-hell with a wizard-based "steps"-system. Define each "step" - -- sequentially in the code, and wire up the callbacks to call the next step: - -- a simple, yet powerful, and easy to understand pattern/approach. - local STEPS = {} - - --- @param info vim.lsp.LocationOpts.OnList - function STEPS._1_on_symbols(info) - M.create_picker { - items = info.items, - --- @param item { text: string } - format_item = function(item) - local s = item.text:gsub('<', '<') - return s - end, - on_finish = STEPS._2_on_symbol_picked, - } - end - - --- @param items { filename: string, lnum: integer, col: integer }[] - function STEPS._2_on_symbol_picked(items) - if #items == 0 then return STEPS._finally() end - - local item = items[1] - - -- Jump to the file/buffer: - local buf = vim - .iter(vim.fn.getbufinfo { buflisted = 1 }) - :find(function(b) return b.name == item.filename end) - if buf ~= nil then - vim.api.nvim_win_set_buf(0, buf.bufnr) - else - vim.cmd.edit(item.filename) - end - - -- Jump to the specific location: - vim.api.nvim_win_set_cursor(0, { item.lnum, item.col - 1 }) - vim.cmd.normal 'zz' - - STEPS._finally() - end - - function STEPS._finally() IS_CODE_SYMBOL_RUNNING = false end - - -- Kick off the async operation: - vim.lsp.buf.document_symbol { on_list = STEPS._1_on_symbols } -end -- }}} - -function M.setup() - utils.ucmd('Files', M.files) - utils.ucmd('Buffers', M.buffers) - utils.ucmd('Lspcodesymbols', M.lsp_code_symbols) -end - -return M diff --git a/examples/surround.lua b/examples/surround.lua index db4aa31..f825dd8 100644 --- a/examples/surround.lua +++ b/examples/surround.lua @@ -1,4 +1,3 @@ -local Buffer = require 'u.buffer' local CodeWriter = require 'u.codewriter' local Range = require 'u.range' local vim_repeat = require 'u.repeat' @@ -69,8 +68,8 @@ local function do_surround(range, bounds) if range.mode == 'v' then range:replace(left .. range:text() .. right) elseif range.mode == 'V' then - local buf = Buffer.current() - local cw = CodeWriter.from_line(range.start:line(), buf.bufnr) + local bufnr = vim.api.nvim_get_current_buf() + local cw = CodeWriter.from_line(range.start:line(), bufnr) -- write the left bound at the current indent level: cw:write(left) @@ -194,7 +193,6 @@ function M.setup() local txt_obj = vim_repeat.is_repeating() and CACHED_DELETE_FROM or vim.fn.getcharstr() CACHED_DELETE_FROM = txt_obj - local buf = Buffer.current() local irange = Range.from_motion('i' .. txt_obj) local arange = Range.from_motion('a' .. txt_obj) if arange == nil or irange == nil then return end @@ -216,11 +214,11 @@ function M.setup() -- `arange` as the final resulting range that holds the modified text -- delete last line, if it is empty: - local last = buf:line(arange.stop.lnum) + local last = Range.from_line(bufnr, arange.stop.lnum) if last:text():match '^%s*$' then last:replace(nil) end -- delete first line, if it is empty: - local first = buf:line(arange.start.lnum) + local first = Range.from_line(bufnr, arange.start.lnum) if first:text():match '^%s*$' then first:replace(nil) end else -- trim start: diff --git a/examples/text-objects.lua b/examples/text-objects.lua index a7b0d53..d012043 100644 --- a/examples/text-objects.lua +++ b/examples/text-objects.lua @@ -1,4 +1,3 @@ -local Buffer = require 'u.buffer' local Pos = require 'u.pos' local Range = require 'u.range' local txtobj = require 'u.txtobj' @@ -7,10 +6,10 @@ local M = {} function M.setup() -- Select whole file: - txtobj.define('ag', function() return Buffer.current():all() end) + txtobj.define('ag', function() return Range.from_buf_text(0) end) -- Select current line: - txtobj.define('a.', function() return Buffer.current():line(Pos.from_pos('.').lnum) end) + txtobj.define('a.', function() return Range.from_line(0, Pos.from_pos('.').lnum) end) -- Select the nearest quote: txtobj.define('aq', function() return Range.find_nearest_quotes() end) diff --git a/lua/u/buffer.lua b/lua/u/buffer.lua deleted file mode 100644 index f27f5c0..0000000 --- a/lua/u/buffer.lua +++ /dev/null @@ -1,131 +0,0 @@ -local Range = require 'u.range' -local Renderer = require('u.renderer').Renderer - ---- @class u.Buffer ---- @field bufnr integer ---- @field b vim.var_accessor ---- @field bo vim.bo ---- @field renderer u.renderer.Renderer -local Buffer = {} -Buffer.__index = Buffer - ---- @param bufnr? number ---- @return u.Buffer -function Buffer.from_nr(bufnr) - if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end - local renderer = Renderer.new(bufnr) - return setmetatable({ - bufnr = bufnr, - b = vim.b[bufnr], - bo = vim.bo[bufnr], - renderer = renderer, - }, Buffer) -end - ---- @return u.Buffer -function Buffer.current() return Buffer.from_nr(0) end - ---- @param listed boolean ---- @param scratch boolean ---- @return u.Buffer -function Buffer.create(listed, scratch) - return Buffer.from_nr(vim.api.nvim_create_buf(listed, scratch)) -end - -function Buffer:set_tmp_options() - self.bo.bufhidden = 'delete' - self.bo.buflisted = false - self.bo.buftype = 'nowrite' -end - -function Buffer:line_count() return vim.api.nvim_buf_line_count(self.bufnr) end - -function Buffer:all() return Range.from_buf_text(self.bufnr) end - -function Buffer:is_empty() return self:line_count() == 1 and self:line(1):text() == '' end - ---- @param line string -function Buffer:append_line(line) - local start = -1 - if self:is_empty() then start = -2 end - vim.api.nvim_buf_set_lines(self.bufnr, start, -1, false, { line }) -end - ---- @param num number 1-based line index -function Buffer:line(num) - if num < 0 then num = self:line_count() + num + 1 end - return Range.from_line(self.bufnr, num) -end - ---- @param start number 1-based line index ---- @param stop number 1-based line index -function Buffer:lines(start, stop) return Range.from_lines(self.bufnr, start, stop) end - ---- @param motion string ---- @param opts? { contains_cursor?: boolean, pos?: u.Pos } -function Buffer:motion(motion, opts) - opts = vim.tbl_extend('force', opts or {}, { bufnr = self.bufnr }) - return Range.from_motion(motion, opts) -end - ---- @param event vim.api.keyset.events|vim.api.keyset.events[] ---- @diagnostic disable-next-line: undefined-doc-name ---- @param opts vim.api.keyset.create_autocmd -function Buffer:autocmd(event, opts) - vim.api.nvim_create_autocmd(event, vim.tbl_extend('force', opts, { buffer = self.bufnr })) -end - ---- @param fn function -function Buffer:call(fn) return vim.api.nvim_buf_call(self.bufnr, fn) end - ---- @param tree u.renderer.Tree -function Buffer:render(tree) return self.renderer:render(tree) end - ---- Filter buffer content through an external command (like Vim's :%!) ---- @param cmd string[] Command to run (with arguments) ---- @param opts? {cwd?: string, preserve_cursor?: boolean} ---- @return nil ---- @throws string Error message if command fails ---- @note Special placeholders in cmd: ---- - $FILE: replaced with the buffer's filename (if any) ---- - $DIR: replaced with the buffer's directory (if any) -function Buffer:filter_cmd(cmd, opts) - opts = opts or {} - local cwd = opts.cwd or vim.uv.cwd() - local old_lines = self:all():lines() - -- Save cursor position if needed, defaulting to true - local save_pos = opts.preserve_cursor ~= false and vim.fn.winsaveview() - - -- Run the command - local result = vim - .system( - -- Replace special placeholders in `cmd` with their values: - vim - .iter(cmd) - :map(function(x) - if x == '$FILE' then return vim.api.nvim_buf_get_name(self.bufnr) end - if x == '$DIR' then return vim.fs.dirname(vim.api.nvim_buf_get_name(self.bufnr)) end - return x - end) - :totable(), - { - cwd = cwd, - stdin = old_lines, - text = true, - } - ) - :wait() - - -- Check for command failure - if result.code ~= 0 then error('Command failed: ' .. (result.stderr or '')) end - - -- Process and apply the result - local new_lines = vim.split(result.stdout, '\n') - if new_lines[#new_lines] == '' then table.remove(new_lines) end - Renderer.patch_lines(self.bufnr, old_lines, new_lines) - - -- Restore cursor position if saved - if save_pos then vim.fn.winrestview(save_pos) end -end - -return Buffer diff --git a/lua/u/codewriter.lua b/lua/u/codewriter.lua index 8dfb2b7..d3362cb 100644 --- a/lua/u/codewriter.lua +++ b/lua/u/codewriter.lua @@ -1,5 +1,3 @@ -local Buffer = require 'u.buffer' - --- @class u.CodeWriter --- @field lines string[] --- @field indent_level number @@ -25,7 +23,8 @@ end --- @param p u.Pos function CodeWriter.from_pos(p) - local line = Buffer.from_nr(p.bufnr):line(p.lnum):text() + local Range = require 'u.range' + local line = Range.from_line(p.bufnr, p.lnum):text() return CodeWriter.from_line(line, p.bufnr) end diff --git a/lua/u/logger.lua b/lua/u/logger.lua deleted file mode 100644 index f89480a..0000000 --- a/lua/u/logger.lua +++ /dev/null @@ -1,73 +0,0 @@ -local M = {} - -local LOG_ROOT = vim.fs.joinpath(vim.fn.stdpath 'cache', 'u.log') - ---- @params name string -function M.file_for_name(name) return vim.fs.joinpath(LOG_ROOT, name .. '.log.jsonl') end - --------------------------------------------------------------------------------- --- Logger class --------------------------------------------------------------------------------- - ---- @class u.Logger ---- @field name string -local Logger = {} -Logger.__index = Logger -M.Logger = Logger - ---- @param name string -function Logger.new(name) - local file_path = M.file_for_name(name) - vim.fn.mkdir(vim.fs.dirname(file_path), 'p') - local self = setmetatable({ name = name }, Logger) - return self -end - ---- @private ---- @param level string -function Logger:write(level, ...) - local data = { ... } - if #data == 1 then data = data[1] end - local f = assert(io.open(M.file_for_name(self.name), 'a'), 'could not open file') - assert( - f:write(vim.json.encode { ts = os.date(), level = level, data = data } .. '\n'), - 'could not write to file' - ) - f:close() -end - -function Logger:trace(...) self:write('INFO', ...) end -function Logger:debug(...) self:write('DEBUG', ...) end -function Logger:info(...) self:write('INFO', ...) end -function Logger:warn(...) self:write('WARN', ...) end -function Logger:error(...) self:write('ERROR', ...) end - -function M.setup() - vim.api.nvim_create_user_command('Logfollow', function(args) - if #args.fargs == 0 then - vim.print 'expected log name' - return - end - - local log_file_path = M.file_for_name(args.fargs[1]) - vim.fn.mkdir(vim.fs.dirname(log_file_path), 'p') - vim.system({ 'touch', log_file_path }):wait() - - vim.cmd.new() - - local winnr = vim.api.nvim_get_current_win() - vim.wo[winnr][0].number = false - vim.wo[winnr][0].relativenumber = false - - vim.cmd.terminal('tail -f "' .. log_file_path .. '"') - vim.cmd.startinsert() - end, { nargs = '*' }) - - vim.api.nvim_create_user_command( - 'Logroot', - function() vim.api.nvim_echo({ { LOG_ROOT } }, false, {}) end, - {} - ) -end - -return M diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua deleted file mode 100644 index fbd59d5..0000000 --- a/lua/u/renderer.lua +++ /dev/null @@ -1,832 +0,0 @@ -function _G.URendererOpFuncSwallow() end - -local ENABLE_LOG = false - -local function log(...) - if not ENABLE_LOG 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() -end - -local M = {} -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 } - ---- @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 & fun(name: string, attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag> -M.h = setmetatable({}, { - __call = function(_, name, attributes, children) - return { - kind = 'tag', - name = name, - attributes = attributes or {}, - children = children, - } - end, - __index = function(_, name) - return function(attributes, children) - return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children) - end - 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: u.renderer.RendererExtmark[] } ---- @field curr { lines: string[], extmarks: u.renderer.RendererExtmark[] } -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 - ---- @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 -function Renderer.new(bufnr) -- {{{ - if bufnr == nil or bufnr == 0 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('my.renderer:' .. tostring(bufnr)) - end - - local self = setmetatable({ - bufnr = bufnr, - ns = vim.b[bufnr]._renderer_ns, - changedtick = 0, - old = { lines = {}, extmarks = {} }, - curr = { lines = {}, extmarks = {} }, - }, Renderer) - - vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, { - buffer = bufnr, - callback = function() self:_on_text_changed() end, - }) - - return self -end -- }}} - ---- @class u.renderer.MarkupOpts ---- @field tree u.renderer.Tree ---- @field on_tag? fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any -function Renderer.markup_to_lines(opts) -- {{{ - --- @type string[] - local lines = {} - - local curr_line1 = 1 - local curr_col1 = 1 -- exclusive: sits one position **beyond** the last inserted text - --- @param s string - local function put(s) - lines[curr_line1] = (lines[curr_line1] or '') .. s - curr_col1 = #lines[curr_line1] + 1 - end - local function put_line() - table.insert(lines, '') - curr_line1 = curr_line1 + 1 - curr_col1 = 1 - end - - --- @param node u.renderer.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 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: - visit(child) - end - else -- luacheck: ignore - 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 -- }}} - ---- @class u.renderer.StringOpts ---- @field tree u.renderer.Tree ---- @field 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 old_lines string[] | nil ---- @param new_lines string[] -function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{ - -- - -- Helpers: - -- - - --- @param start integer - --- @param end_ integer - --- @param strict_indexing boolean - --- @param replacement string[] - local function _set_lines(start, end_, strict_indexing, replacement) - vim.api.nvim_buf_set_lines(bufnr, start, end_, strict_indexing, replacement) - end - - --- @param start_row integer - --- @param start_col integer - --- @param end_row integer - --- @param end_col integer - --- @param replacement string[] - local function _set_text(start_row, start_col, end_row, end_col, replacement) - vim.api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, replacement) - 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) - for _, line_change in ipairs(line_changes) do - local lnum0 = line_change.index - 1 - - if line_change.kind == 'add' then - _set_lines(lnum0, lnum0, true, { line_change.item }) - 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, '')) - - for _, col_change in ipairs(col_changes) do - local cnum0 = col_change.index - 1 - if col_change.kind == 'add' then - _set_text(lnum0, cnum0, lnum0, cnum0, { col_change.item }) - elseif col_change.kind == 'change' then - _set_text(lnum0, cnum0, lnum0, cnum0 + 1, { col_change.to }) - elseif col_change.kind == 'delete' then - _set_text(lnum0, cnum0, lnum0, cnum0 + 1, {}) - else -- luacheck: ignore - -- No change - end - end - elseif line_change.kind == 'delete' then - _set_lines(lnum0, lnum0 + 1, true, {}) - else -- luacheck: ignore - -- No change - end - end -end -- }}} - ---- @param tree u.renderer.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) } - self.changedtick = changedtick - end - - --- @type u.renderer.RendererExtmark[] - local extmarks = {} - - --- @type string[] - local lines = Renderer.markup_to_lines { - tree = tree, - - on_tag = function(tag, start0, stop0) -- {{{ - if tag.name == 'text' then - local hl = tag.attributes.hl - if type(hl) == 'string' then - tag.attributes.extmark = tag.attributes.extmark or {} - tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or hl - end - - local extmark_opts = tag.attributes.extmark or {} - - -- 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: - vim.keymap.set(mode, lhs, function() - local result = self:_expr_map_callback(mode, lhs) - -- If the handler indicates that it wants to swallow the event, - -- we have to convert that intention into something compatible - -- with expr-mappings, which don't support '' (they try to - -- execute the literal characters). We'll use the 'g@' operator - -- to do that, forwarding the event to an operatorfunc that does - -- nothing: - if result == '' then - if mode == 'i' then - return '' - else - vim.go.operatorfunc = 'v:lua.URendererOpFuncSwallow' - return 'g@ ' - end - end - return result - end, { buffer = self.bufnr, expr = true, replace_keycodes = true }) - end - end - - table.insert(extmarks, { - start = start0, - stop = stop0, - opts = extmark_opts, - tag = tag, - }) - end - end, -- }}} - } - - self.old = self.curr - self.curr = { lines = lines, extmarks = extmarks } - self:_reconcile() - vim.cmd.doautocmd { args = { 'User', 'Renderer:' .. tostring(self.bufnr) .. ':render' } } -end -- }}} - ---- @private -function Renderer:_reconcile() -- {{{ - -- - -- Step 1: morph the text to the desired state: - -- - Renderer.patch_lines(self.bufnr, self.old.lines, self.curr.lines) - self.changedtick = vim.b[self.bufnr].changedtick - - -- - -- Step 2: reconcile extmarks: - -- You may be tempted to try to keep track of which extmarks are needed, and - -- only delete those that are not needed. However, each time a tree is - -- rendered, brand new extmarks are created. For simplicity, it is better to - -- just delete all extmarks, and recreate them. - -- - - -- Clear current extmarks: - vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1) - - -- Set current extmarks: - for _, extmark in ipairs(self.curr.extmarks) do - extmark.id = vim.api.nvim_buf_set_extmark( - self.bufnr, - self.ns, - extmark.start[1], - extmark.start[2], - vim.tbl_extend('force', { - id = extmark.id, - end_row = extmark.stop[1], - end_col = extmark.stop[2], - -- If we change the text starting from the beginning (where the extmark - -- is), we don't want the extmark to move to the right. - right_gravity = false, - -- If we change the text starting from the end (where the end extmark - -- is), we don't want the extmark to stay stationary: we want it to - -- move to the right. - end_right_gravity = true, - }, extmark.opts) - ) - end - - self.old = self.curr -end -- }}} - ---- @private ---- @param mode string ---- @param lhs string -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) - local pos_infos = self:get_tags_at(pos0) - log('_expr_map_callback: pos_infos:', pos_infos) - - if #pos_infos == 0 then return lhs end - - -- Find the first tag that is listening for this event: - local cancel = false - for _, pos_info in ipairs(pos_infos) do - 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(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: - cancel = true - else - return result - end - end - end - - -- Resort to default behavior: - return cancel and '' or lhs -end -- }}} - -function Renderer:_on_text_changed() -- {{{ - -- Reset changedtick, so that the reconciler knows to refresh its cached - -- buffer-content before computing the diff: - self.changedtick = 0 - - --- @type integer, integer - local l, c = unpack(vim.api.nvim_win_get_cursor(0)) - l = l - 1 -- make it actually 0-based - local pos_infos = self:get_tags_at({ l, c }, 'i') - log('_on_text_changed', { cursor_0_0 = { l, c }, pos_infos = pos_infos }) - for _, pos_info in ipairs(pos_infos) do - local extmark_inf = pos_info.extmark - local tag = pos_info.tag - - local on_change = tag.attributes.on_change - if on_change and type(on_change) == 'function' then - local extmark = - vim.api.nvim_buf_get_extmark_by_id(self.bufnr, self.ns, extmark_inf.id, { details = true }) - - --- @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', { - pos_info = pos_info, - curr_extmark = { - start_row0 = start_row0, - start_col0 = start_col0, - end_row0 = end_row0, - end_col0 = end_col0, - details = details, - }, - }) - - if start_row0 == end_row0 and start_col0 == end_col0 then - on_change '' - return - end - - local buf_max_line0 = math.max(1, vim.api.nvim_buf_line_count(self.bufnr) - 1) - if end_row0 > buf_max_line0 then - end_row0 = buf_max_line0 - local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1] - or '' - end_col0 = last_line:len() - end - if end_col0 == 0 then - end_row0 = end_row0 - 1 - local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1] - or '' - end_col0 = last_line:len() - end - log('_on_text_changed: after position correction', { - curr_extmark = { - start_row0 = start_row0, - start_col0 = start_col0, - end_row0 = end_row0, - end_col0 = end_col0, - }, - }) - - if start_row0 == end_row0 and start_col0 == end_col0 then - on_change '' - return - end - - local pos1 = { self.bufnr, start_row0 + 1, start_col0 + 1 } - 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 ', { - { pos1, pos2 }, - }) - vim.api.nvim_echo({ - { '(u.nvim:getregion:invalid-pos) ', 'ErrorMsg' }, - { - '{ start, end } = ' .. vim.inspect({ pos1, pos2 }, { newline = ' ', indent = '' }), - }, - }, true, {}) - error(lines) - end - local text = table.concat(lines, '\n') - on_change(text) - end - end -end -- }}} - ---- @private -function Renderer:_debug() -- {{{ - local prev_w = vim.api.nvim_get_current_win() - vim.cmd.vnew() - local info_bufnr = vim.api.nvim_get_current_buf() - vim.bo.bufhidden = 'delete' - vim.bo.buflisted = false - vim.bo.buftype = 'nowrite' - - local ids = {} - local function cleanup() - for _, id in ipairs(ids) do - vim.api.nvim_del_autocmd(id) - end - vim.api.nvim_buf_delete(info_bufnr, { force = true }) - end - - local function autocmd_callback() - if vim.api.nvim_get_current_win() ~= prev_w then return end - - local l, c = unpack(vim.api.nvim_win_get_cursor(0)) - l = l - 1 -- make it actually 0-based - - local info = { - cursor = { - pos = { l, c }, - tags = self:get_tags_at { l, c }, - extmarks = vim.api.nvim_buf_get_extmarks( - self.bufnr, - self.ns, - { l, c }, - { l, c }, - { details = true, overlap = true } - ), - }, - computed = { - extmarks = self.curr.extmarks, - }, - } - vim.api.nvim_buf_set_lines(info_bufnr, 0, -1, true, vim.split(vim.inspect(info), '\n')) - end - - table.insert( - ids, - vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, { - callback = autocmd_callback, - }) - ) - table.insert( - ids, - vim.api.nvim_create_autocmd({ 'User' }, { - pattern = 'Renderer:' .. tostring(self.bufnr) .. ':render', - callback = autocmd_callback, - }) - ) - table.insert( - ids, - vim.api.nvim_create_autocmd('WinClosed', { - pattern = tostring(vim.api.nvim_get_current_win()), - callback = cleanup, - }) - ) - table.insert( - ids, - vim.api.nvim_create_autocmd('WinClosed', { - pattern = tostring(prev_w), - callback = cleanup, - }) - ) - - vim.api.nvim_set_current_win(prev_w) -end -- }}} - ---- Returns pairs of extmarks and tags associate with said extmarks. The ---- returned tags/extmarks are sorted smallest (innermost) to largest ---- (outermost). ---- ---- @private (private for now) ---- @param pos0 [number, number] ---- @param mode string? ---- @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 - mode = mode:sub(1, 1) -- we don't care about sub-modes - - local raw_overlapping_extmarks = vim.api.nvim_buf_get_extmarks( - self.bufnr, - self.ns, - pos0, - pos0, - { details = true, overlap = true } - ) - log( - 'get_tags_at: context:', - { pos0 = pos0, mode = mode, raw_overlapping_extmarks = raw_overlapping_extmarks } - ) - - -- 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 u.renderer.RendererExtmark[] - local mapped_extmarks = vim - .iter(raw_overlapping_extmarks) - :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 } - end - return { id = id, start = start, stop = stop, opts = details } - end) - :totable() - - local intersecting_extmarks = vim - .iter(mapped_extmarks) - :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 - 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): - table.sort( - intersecting_extmarks, - --- @param x1 u.renderer.RendererExtmark - --- @param x2 u.renderer.RendererExtmark - function(x1, x2) - if - x1.start[1] == x2.start[1] - and x1.start[2] == x2.start[2] - and x1.stop[1] == x2.stop[1] - and x1.stop[2] == x2.stop[2] - then - return x1.id < x2.id - end - - return x1.start[1] >= x2.start[1] - and x1.start[2] >= x2.start[2] - and x1.stop[1] <= x2.stop[1] - and x1.stop[2] <= x2.stop[2] - end - ) - - -- 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 - -- 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 }[] - local matching_tags = vim - .iter(intersecting_extmarks) - :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 - end - end) - :totable() - - return matching_tags -end -- }}} - ---- @private ---- @param tag_or_id string | u.renderer.Tag ---- @return { start: [number, number], stop: [number, number] } | nil -function Renderer:get_tag_bounds(tag_or_id) -- {{{ - for _, x in ipairs(self.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 - if does_tag_match then return pos end - end -end -- }}} - --- }}} - --- TreeBuilder {{{ ---- @class u.renderer.TreeBuilder ---- @field private nodes u.renderer.Node[] -local TreeBuilder = {} -TreeBuilder.__index = TreeBuilder -M.TreeBuilder = TreeBuilder - -function TreeBuilder.new() - local self = setmetatable({ nodes = {} }, TreeBuilder) - return self -end - ---- @param nodes u.renderer.Tree ---- @return u.renderer.TreeBuilder -function TreeBuilder:put(nodes) - table.insert(self.nodes, nodes) - return self -end - ---- @param name string ---- @param attributes? table ---- @param children? u.renderer.Node | u.renderer.Node[] ---- @return u.renderer.TreeBuilder -function TreeBuilder:put_h(name, attributes, children) - local tag = M.h(name, attributes, children) - table.insert(self.nodes, tag) - return self -end - ---- @param fn fun(tb: u.renderer.TreeBuilder): any ---- @return u.renderer.TreeBuilder -function TreeBuilder:nest(fn) - local nested_writer = TreeBuilder.new() - fn(nested_writer) - table.insert(self.nodes, nested_writer.nodes) - return self -end - ---- @generic T ---- @param arr T[] ---- @param f fun(tb: u.renderer.TreeBuilder, item: T, idx: number): any -function TreeBuilder:ipairs(arr, f) - return self:nest(function(tb) - for idx, item in ipairs(arr) do - f(tb, item, idx) - end - end) -end - ---- @param tab table ---- @param f fun(tb: u.renderer.TreeBuilder, key: any, value: any): any -function TreeBuilder:pairs(tab, f) - return self:nest(function(tb) - for k, v in pairs(tab) do - f(tb, k, v) - end - end) -end - ---- @return u.renderer.Tree -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 }) ---- @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) } ---- @return u.renderer.LevenshteinChange[] The changes, from last (greatest index) to first (smallest index). -function H.levenshtein(x, y, cost) - -- 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 - -- that is tailored to working better with how NeoVim manipulates text. I've - -- done no further investigation in this area, however, so it's impossible to - -- tell if such tuning would produce real benefit. For now, I'm leaving this - -- 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 - - local m, n = #x, #y - -- Initialize the distance matrix - local dp = {} - for i = 0, m do - dp[i] = {} - for j = 0, n do - dp[i][j] = 0 - end - end - - -- Fill the base cases - for i = 0, m do - dp[i][0] = i - end - for j = 0, n do - dp[0][j] = j - end - - -- Compute the Levenshtein distance dynamically - for i = 1, m do - for j = 1, n do - if x[i] == y[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]) - dp[i][j] = math.min(cost_delete, cost_add, cost_change) - end - end - end - - -- Backtrack to find the changes - local i = m - local j = n - --- @type u.renderer.LevenshteinChange[] - local changes = {} - - while i > 0 or j > 0 do - local default_cost = dp[i][j] - local cost_of_change = (i > 0 and j > 0) and dp[i - 1][j - 1] or default_cost - local cost_of_add = j > 0 and dp[i][j - 1] or default_cost - local cost_of_delete = i > 0 and dp[i - 1][j] or default_cost - - --- @param u number - --- @param v number - --- @param w number - local function is_first_min(u, v, w) return u <= v and u <= w end - - if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then - -- potential change - if x[i] ~= y[j] then - --- @type u.renderer.LevenshteinChange - local change = { kind = 'change', from = x[i], index = i, to = y[j] } - table.insert(changes, change) - end - i = i - 1 - j = j - 1 - 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 } - 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 } - table.insert(changes, change) - i = i - 1 - else - error 'unreachable' - end - end - - return changes -end --- }}} - -return M diff --git a/lua/u/repeat.lua b/lua/u/repeat.lua index 71755c0..5da9b31 100644 --- a/lua/u/repeat.lua +++ b/lua/u/repeat.lua @@ -1,7 +1,7 @@ local M = {} local IS_REPEATING = false ---- @type function +--- @type function|nil local REPEAT_ACTION = nil local function is_repeatable_last_mutator() return vim.b.changedtick <= (vim.b.my_changedtick or 0) end @@ -9,6 +9,7 @@ local function is_repeatable_last_mutator() return vim.b.changedtick <= (vim.b.m --- @param f fun() function M.run_repeatable(f) REPEAT_ACTION = f + ---@diagnostic disable-next-line: need-check-nil REPEAT_ACTION() vim.b.my_changedtick = vim.b.changedtick end diff --git a/lua/u/tracker.lua b/lua/u/tracker.lua deleted file mode 100644 index 57a8e96..0000000 --- a/lua/u/tracker.lua +++ /dev/null @@ -1,311 +0,0 @@ -local M = {} - -M.debug = false - --------------------------------------------------------------------------------- --- class Signal --------------------------------------------------------------------------------- - ---- @class u.Signal ---- @field name? string ---- @field private changing boolean ---- @field private value T ---- @field private subscribers table ---- @field private on_dispose_callbacks function[] -local Signal = {} -M.Signal = Signal -Signal.__index = Signal - ---- @param value `T` ---- @param name? string ---- @return u.Signal -function Signal:new(value, name) - local obj = setmetatable({ - name = name, - changing = false, - value = value, - subscribers = {}, - on_dispose_callbacks = {}, - }, self) - return obj -end - ---- @param value T -function Signal:set(value) - self.value = value - - -- We don't handle cyclic updates: - if self.changing then - if M.debug then - vim.notify( - 'circular dependency detected' .. (self.name and (' in ' .. self.name) or ''), - vim.log.levels.WARN - ) - end - return - end - - local prev_changing = self.changing - self.changing = true - local ok = true - local err = nil - for _, cb in ipairs(self.subscribers) do - local ok2, err2 = pcall(cb, value) - if not ok2 then - ok = false - err = err or err2 - end - end - self.changing = prev_changing - - if not ok then - vim.notify( - 'error notifying' .. (self.name and (' in ' .. self.name) or '') .. ': ' .. tostring(err), - vim.log.levels.WARN - ) - error(err) - end -end - ---- @param value T -function Signal:schedule_set(value) - vim.schedule(function() self:set(value) end) -end - ---- @return T -function Signal:get() - local ctx = M.ExecutionContext.current() - if ctx then ctx:track(self) end - return self.value -end - ---- @param fn function -function Signal:update(fn) self:set(fn(self.value)) end - ---- @param fn function -function Signal:schedule_update(fn) self:schedule_set(fn(self.value)) end - ---- @generic U ---- @param fn fun(value: T): `U` ---- @return u.Signal -function Signal:map(fn) - local mapped_signal = M.create_memo(function() - local value = self:get() - return fn(value) - end, self.name and self.name .. ':mapped' or nil) - return mapped_signal -end - ---- @return u.Signal -function Signal:clone() - return self:map(function(x) return x end) -end - ---- @param fn fun(value: T): boolean ---- @return u.Signal -function Signal:filter(fn) - local filtered_signal = M.create_signal(nil, self.name and self.name .. ':filtered' or nil) - local unsubscribe_from_self = self:subscribe(function(value) - if fn(value) then filtered_signal:set(value) end - end) - filtered_signal:on_dispose(unsubscribe_from_self) - return filtered_signal -end - ---- @param ms number ---- @return u.Signal -function Signal:debounce(ms) - local function set_timeout(timeout, callback) - local timer = assert((vim.uv or vim.loop).new_timer(), 'could not create new timer') - timer:start(timeout, 0, function() - timer:stop() - timer:close() - callback() - end) - return timer - end - - local filtered = M.create_signal(self.value, self.name and self.name .. ':debounced' or nil) - - --- @diagnostic disable-next-line: undefined-doc-name - --- @type { queued: { value: T, ts: number }[], timer?: uv.uv_timer_t } - local state = { queued = {}, timer = nil } - local function clear_timeout() - if state.timer == nil then return end - pcall(function() - --- @diagnostic disable-next-line: undefined-field - state.timer:stop() - --- @diagnostic disable-next-line: undefined-field - state.timer:close() - end) - state.timer = nil - end - - local unsubscribe_from_self = self:subscribe(function(value) - -- Stop any previously running timer: - if state.timer then clear_timeout() end - local now_ms = (vim.uv or vim.loop).hrtime() / 1e6 - - -- If there is anything older than `ms` in our queue, emit it: - local older_than_ms = vim - .iter(state.queued) - :filter(function(item) return now_ms - item.ts > ms end) - :totable() - local last_older_than_ms = older_than_ms[#older_than_ms] - if last_older_than_ms then - filtered:set(last_older_than_ms.value) - state.queued = {} - end - - -- overwrite anything young enough - table.insert(state.queued, { value = value, ts = now_ms }) - state.timer = set_timeout(ms, function() - vim.schedule(function() filtered:set(value) end) - -- If a timer was allowed to run to completion, that means that no other - -- item has been queued, since the timer is reset every time a new item - -- comes in. This means we can reset the queue - clear_timeout() - state.queued = {} - end) - end) - filtered:on_dispose(unsubscribe_from_self) - - return filtered -end - ---- @param callback function -function Signal:subscribe(callback) - table.insert(self.subscribers, callback) - return function() self:unsubscribe(callback) end -end - ---- @param callback function -function Signal:on_dispose(callback) table.insert(self.on_dispose_callbacks, callback) end - ---- @param callback function -function Signal:unsubscribe(callback) - for i, cb in ipairs(self.subscribers) do - if cb == callback then - table.remove(self.subscribers, i) - break - end - end -end - -function Signal:dispose() - self.subscribers = {} - for _, callback in ipairs(self.on_dispose_callbacks) do - callback() - end -end - --------------------------------------------------------------------------------- --- class ExecutionContext --------------------------------------------------------------------------------- - ---- @type u.ExecutionContext|nil -local CURRENT_CONTEXT = nil - ---- @class u.ExecutionContext ---- @field signals table -local ExecutionContext = {} -M.ExecutionContext = ExecutionContext -ExecutionContext.__index = ExecutionContext - ---- @return u.ExecutionContext -function ExecutionContext.new() - return setmetatable({ - signals = {}, - subscribers = {}, - }, ExecutionContext) -end - -function ExecutionContext.current() return CURRENT_CONTEXT end - ---- @param fn function ---- @param ctx u.ExecutionContext -function ExecutionContext.run(fn, ctx) - local oldCtx = CURRENT_CONTEXT - CURRENT_CONTEXT = ctx - local result - local success, err = pcall(function() result = fn() end) - - CURRENT_CONTEXT = oldCtx - - if not success then error(err) end - - return result -end - -function ExecutionContext:track(signal) self.signals[signal] = true end - ---- @param callback function -function ExecutionContext:subscribe(callback) - local wrapped_callback = function() callback() end - for signal in pairs(self.signals) do - signal:subscribe(wrapped_callback) - end - - return function() - for signal in pairs(self.signals) do - signal:unsubscribe(wrapped_callback) - end - end -end - -function ExecutionContext:dispose() - for signal, _ in pairs(self.signals) do - signal:dispose() - end - self.signals = {} -end - --------------------------------------------------------------------------------- --- Helpers --------------------------------------------------------------------------------- - ---- @generic T ---- @param value `T` ---- @param name? string ---- @return u.Signal -function M.create_signal(value, name) return Signal:new(value, name) end - ---- @generic T ---- @param fn fun(): `T` ---- @param name? string ---- @return u.Signal -function M.create_memo(fn, name) - --- @type u.Signal | nil - local result - local unsubscribe = M.create_effect(function() - local value = fn() - if name and M.debug then vim.notify(name) end - if result then - result:set(value) - else - result = M.create_signal(value, name and ('m.s:' .. name) or nil) - end - end, name) - assert(result):on_dispose(unsubscribe) - return assert(result) -end - ---- @param fn function ---- @param name? string -function M.create_effect(fn, name) - local ctx = M.ExecutionContext.new() - M.ExecutionContext.run(fn, ctx) - return ctx:subscribe(function() - if name and M.debug then - local deps = vim - .iter(vim.tbl_keys(ctx.signals)) - :map(function(s) return s.name end) - :filter(function(nm) return nm ~= nil end) - :join ',' - vim.notify(name .. '(deps=' .. deps .. ')') - end - fn() - end) -end - -return M diff --git a/lua/u/utils.lua b/lua/u/utils.lua index 9427dc4..b85bf04 100644 --- a/lua/u/utils.lua +++ b/lua/u/utils.lua @@ -36,39 +36,6 @@ local M = {} --- @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` 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 - table.insert(t, x) - vim.print(t) - return x -end - --- Creates a user command with enhanced argument processing. --- Automatically computes range information and attaches it as `args.info`. --- @@ -156,36 +123,4 @@ function M.create_delegated_cmd_args(current_args) 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/mise.toml b/mise.toml index 362d6bf..b7c1356 100644 --- a/mise.toml +++ b/mise.toml @@ -14,7 +14,7 @@ jq = "1.8.1" # installed. In the tests, we will override with `eval $(nvimv env VERSION)`, # but to avoid having to litter a bunch of commands with that environment # initialization, this just makes things simpler: -neovim = "0.11.5" +neovim = "0.12.0" stylua = "2.3.1" "cargo:emmylua_ls" = "0.20.0" "cargo:emmylua_check" = "0.20.0" diff --git a/spec/buffer_spec.lua b/spec/buffer_spec.lua deleted file mode 100644 index f56fdeb..0000000 --- a/spec/buffer_spec.lua +++ /dev/null @@ -1,31 +0,0 @@ ---- @diagnostic disable: undefined-field, need-check-nil -local Buffer = require 'u.buffer' -local withbuf = loadfile './spec/withbuf.lua'() - -describe('Buffer', function() - it('should replace all lines', function() - withbuf({}, function() - local buf = Buffer.from_nr() - buf:all():replace 'bleh' - local actual_lines = vim.api.nvim_buf_get_lines(buf.bufnr, 0, -1, false) - assert.are.same({ 'bleh' }, actual_lines) - end) - end) - - it('should replace all but first and last lines', function() - withbuf({ - 'one', - 'two', - 'three', - }, function() - local buf = Buffer.from_nr() - buf:lines(2, -2):replace 'too' - local actual_lines = vim.api.nvim_buf_get_lines(buf.bufnr, 0, -1, false) - assert.are.same({ - 'one', - 'too', - 'three', - }, actual_lines) - end) - end) -end) diff --git a/spec/range_spec.lua b/spec/range_spec.lua index ee40e68..6770c32 100644 --- a/spec/range_spec.lua +++ b/spec/range_spec.lua @@ -863,34 +863,40 @@ describe('Range', function() end) it('discerns range bounds from extmarks beyond the end of the buffer', function() - local Buffer = require 'u.buffer' + local function set_tmp_options(bufnr) + vim.bo[bufnr].bufhidden = 'delete' + vim.bo[bufnr].buflisted = false + vim.bo[bufnr].buftype = 'nowrite' + end vim.cmd.vnew() - local left = Buffer.current() - left:set_tmp_options() + local left_bufnr = vim.api.nvim_get_current_buf() + set_tmp_options(left_bufnr) + local left = Range.from_buf_text(left_bufnr) vim.cmd.vnew() - local right = Buffer.current() - right:set_tmp_options() + local right_bufnr = vim.api.nvim_get_current_buf() + set_tmp_options(left_bufnr) + local right = Range.from_buf_text(right_bufnr) - left:all():replace { + left:replace { 'one', 'two', 'three', } - local left_all_ext = left:all():save_to_extmark() + local left_all_ext = left:save_to_extmark() - right:all():replace { + right:replace { 'foo', 'bar', } - vim.api.nvim_set_current_buf(right.bufnr) + vim.api.nvim_set_current_buf(right_bufnr) vim.cmd [[normal! ggyG]] - vim.api.nvim_set_current_buf(left.bufnr) + vim.api.nvim_set_current_buf(left_bufnr) vim.cmd [[normal! ggVGp]] assert.are.same({ 'foo', 'bar' }, left_all_ext:range():lines()) - vim.api.nvim_buf_delete(left.bufnr, { force = true }) - vim.api.nvim_buf_delete(right.bufnr, { force = true }) + vim.api.nvim_buf_delete(left_bufnr, { force = true }) + vim.api.nvim_buf_delete(right_bufnr, { force = true }) end) end) diff --git a/spec/renderer_spec.lua b/spec/renderer_spec.lua deleted file mode 100644 index dbe8cfb..0000000 --- a/spec/renderer_spec.lua +++ /dev/null @@ -1,298 +0,0 @@ ---- @diagnostic disable: undefined-field, need-check-nil -local R = require 'u.renderer' -local withbuf = loadfile './spec/withbuf.lua'() - -local function getlines() return vim.api.nvim_buf_get_lines(0, 0, -1, true) end - -describe('Renderer', function() - it('should render text in an empty buffer', function() - withbuf({}, function() - local r = R.Renderer.new(0) - r:render { 'hello', ' ', 'world' } - assert.are.same(getlines(), { 'hello world' }) - end) - end) - - it('should result in the correct text after repeated renders', function() - withbuf({}, function() - local r = R.Renderer.new(0) - r:render { 'hello', ' ', 'world' } - assert.are.same(getlines(), { 'hello world' }) - - r:render { 'goodbye', ' ', 'world' } - assert.are.same(getlines(), { 'goodbye world' }) - - r:render { 'hello', ' ', 'universe' } - assert.are.same(getlines(), { 'hello universe' }) - end) - end) - - it('should handle tags correctly', function() - withbuf({}, function() - local r = R.Renderer.new(0) - r:render { - R.h('text', { hl = 'HighlightGroup' }, 'hello '), - R.h('text', { hl = 'HighlightGroup' }, 'world'), - } - assert.are.same(getlines(), { 'hello world' }) - end) - end) - - it('should reconcile added lines', function() - withbuf({}, function() - local r = R.Renderer.new(0) - r:render { 'line 1', '\n', 'line 2' } - assert.are.same(getlines(), { 'line 1', 'line 2' }) - - -- Add a new line: - r:render { 'line 1', '\n', 'line 2\n', 'line 3' } - assert.are.same(getlines(), { 'line 1', 'line 2', 'line 3' }) - end) - end) - - it('should reconcile deleted lines', function() - withbuf({}, function() - local r = R.Renderer.new(0) - r:render { 'line 1', '\nline 2', '\nline 3' } - assert.are.same(getlines(), { 'line 1', 'line 2', 'line 3' }) - - -- Remove a line: - r:render { 'line 1', '\nline 3' } - assert.are.same(getlines(), { 'line 1', 'line 3' }) - end) - end) - - it('should handle multiple nested elements', function() - withbuf({}, function() - local r = R.Renderer.new(0) - r:render { - R.h('text', {}, { - 'first line', - }), - '\n', - R.h('text', {}, 'second line'), - } - assert.are.same(getlines(), { 'first line', 'second line' }) - - r:render { - R.h('text', {}, 'updated first line'), - '\n', - R.h('text', {}, 'third line'), - } - assert.are.same(getlines(), { 'updated first line', 'third line' }) - end) - end) - - -- - -- get_tags_at - -- - - it('should return no extmarks for an empty buffer', function() - withbuf({}, function() - local r = R.Renderer.new(0) - local pos_infos = r:get_tags_at { 0, 0 } - assert.are.same(pos_infos, {}) - end) - end) - - it('should return correct extmark for a given position', function() - withbuf({}, function() - local r = R.Renderer.new(0) - r:render { - R.h('text', { hl = 'HighlightGroup1' }, 'Hello'), - R.h('text', { hl = 'HighlightGroup2' }, ' World'), - } - - local pos_infos = r:get_tags_at { 0, 2 } - assert.are.same(#pos_infos, 1) - assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1') - assert.are.same(pos_infos[1].extmark.start, { 0, 0 }) - assert.are.same(pos_infos[1].extmark.stop, { 0, 5 }) - pos_infos = r:get_tags_at { 0, 4 } - assert.are.same(#pos_infos, 1) - assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1') - assert.are.same(pos_infos[1].extmark.start, { 0, 0 }) - assert.are.same(pos_infos[1].extmark.stop, { 0, 5 }) - - pos_infos = r:get_tags_at { 0, 5 } - assert.are.same(#pos_infos, 1) - assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2') - assert.are.same(pos_infos[1].extmark.start, { 0, 5 }) - assert.are.same(pos_infos[1].extmark.stop, { 0, 11 }) - - -- In insert mode, bounds are eagerly included: - pos_infos = r:get_tags_at({ 0, 5 }, 'i') - assert.are.same(#pos_infos, 2) - assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1') - assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup2') - end) - end) - - it('should return multiple extmarks for overlapping text', function() - withbuf({}, function() - local r = R.Renderer.new(0) - r:render { - R.h('text', { hl = 'HighlightGroup1' }, { - 'Hello', - R.h( - 'text', - { hl = 'HighlightGroup2', extmark = { hl_group = 'HighlightGroup2' } }, - ' World' - ), - }), - } - - local pos_infos = r:get_tags_at { 0, 5 } - - assert.are.same(#pos_infos, 2) - assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2') - assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup1') - end) - end) - - it('repeated patch_lines calls should not change the buffer content', function() - local lines = { - [[{ {]], - [[ bounds = {]], - [[ start1 = { 1, 1 },]], - [[ stop1 = { 4, 1 }]], - [[ },]], - [[ end_right_gravity = true,]], - [[ id = 1,]], - [[ ns_id = 623,]], - [[ ns_name = "my.renderer:91",]], - [[ right_gravity = false]], - [[ } }]], - [[]], - } - withbuf(lines, function() - local Buffer = require 'u.buffer' - R.Renderer.patch_lines(0, nil, lines) - assert.are.same(Buffer.current():all():lines(), lines) - - R.Renderer.patch_lines(0, lines, lines) - assert.are.same(Buffer.current():all():lines(), lines) - - R.Renderer.patch_lines(0, lines, lines) - assert.are.same(Buffer.current():all():lines(), lines) - end) - end) - - it('should fire text-changed events', function() - withbuf({}, function() - local Buffer = require 'u.buffer' - local buf = Buffer.current() - local r = R.Renderer.new(0) - local captured_changed_text = '' - r:render { - R.h('text', { - on_change = function(txt) captured_changed_text = txt end, - }, { - 'one\n', - 'two\n', - 'three\n', - }), - } - - vim.fn.setreg('"', 'bleh') - vim.cmd [[normal! ggVGp]] - -- For some reason, the autocmd does not fire in the busted environment. - -- We'll call the handler ourselves: - r:_on_text_changed() - - assert.are.same(buf:all():text(), 'bleh') - assert.are.same(captured_changed_text, 'bleh') - - vim.fn.setreg('"', '') - vim.cmd [[normal! ggdG]] - -- We'll call the handler ourselves: - r:_on_text_changed() - - assert.are.same(buf:all():text(), '') - assert.are.same(captured_changed_text, '') - end) - - withbuf({}, function() - local Buffer = require 'u.buffer' - local buf = Buffer.current() - local r = R.Renderer.new(0) - --- @type string? - local captured_changed_text = nil - r:render { - 'prefix:', - R.h('text', { - on_change = function(txt) captured_changed_text = txt end, - }, { - 'one', - }), - 'suffix', - } - - vim.fn.setreg('"', 'bleh') - vim.api.nvim_win_set_cursor(0, { 1, 9 }) - vim.cmd [[normal! vhhd]] - -- For some reason, the autocmd does not fire in the busted environment. - -- We'll call the handler ourselves: - r:_on_text_changed() - - assert.are.same(buf:all():text(), 'prefix:suffix') - assert.are.same(captured_changed_text, '') - end) - end) - - it('should find tags by position', function() - withbuf({}, function() - local r = R.Renderer.new(0) - r:render { - 'pre', - R.h('text', { - id = 'outer', - }, { - 'inner-pre', - R.h('text', { - id = 'inner', - }, { - 'inner-text', - }), - 'inner-post', - }), - 'post', - } - - local tags = r:get_tags_at { 0, 11 } - assert.are.same(#tags, 1) - assert.are.same(tags[1].tag.attributes.id, 'outer') - - tags = r:get_tags_at { 0, 12 } - assert.are.same(#tags, 2) - assert.are.same(tags[1].tag.attributes.id, 'inner') - assert.are.same(tags[2].tag.attributes.id, 'outer') - end) - end) - - it('should find tags by id', function() - withbuf({}, function() - local r = R.Renderer.new(0) - r:render { - R.h('text', { - id = 'outer', - }, { - 'inner-pre', - R.h('text', { - id = 'inner', - }, { - 'inner-text', - }), - 'inner-post', - }), - 'post', - } - - local bounds = r:get_tag_bounds 'outer' - assert.are.same(bounds, { start = { 0, 0 }, stop = { 0, 29 } }) - - bounds = r:get_tag_bounds 'inner' - assert.are.same(bounds, { start = { 0, 9 }, stop = { 0, 19 } }) - end) - end) -end) diff --git a/spec/tracker_spec.lua b/spec/tracker_spec.lua deleted file mode 100644 index 7457d6b..0000000 --- a/spec/tracker_spec.lua +++ /dev/null @@ -1,207 +0,0 @@ ---- @diagnostic disable: undefined-field, need-check-nil -local tracker = require 'u.tracker' -local Signal = tracker.Signal -local ExecutionContext = tracker.ExecutionContext - -describe('Signal', function() - local signal - - before_each(function() signal = Signal:new(0, 'testSignal') end) - - it('should initialize with correct parameters', function() - assert.is.equal(signal.value, 0) - assert.is.equal(signal.name, 'testSignal') - assert.is.not_nil(signal.subscribers) - assert.is.equal(#signal.subscribers, 0) - assert.is.equal(signal.changing, false) - end) - - it('should set new value and notify subscribers', function() - local called = false - signal:subscribe(function(value) - called = true - assert.is.equal(value, 42) - end) - - signal:set(42) - assert.is.equal(called, true) - end) - - it('should not notify subscribers during circular dependency', function() - signal.changing = true - local notified = false - - signal:subscribe(function() notified = true end) - - signal:set(42) - assert.is.equal(notified, false) -- No notification should occur - end) - - it('should get current value', function() - signal:set(100) - assert.is.equal(signal:get(), 100) - end) - - it('should update value with function', function() - signal:set(10) - signal:update(function(value) return value * 2 end) - assert.is.equal(signal:get(), 20) - end) - - it('should dispose subscribers', function() - local called = false - local unsubscribe = signal:subscribe(function() called = true end) - - unsubscribe() - signal:set(10) - assert.is.equal(called, false) -- Should not be notified - end) - - describe('Signal:map', function() - it('should transform the signal value', function() - local test_signal = Signal:new(5) - local mapped_signal = test_signal:map(function(value) return value * 2 end) - - assert.is.equal(mapped_signal:get(), 10) -- Initial transformation - test_signal:set(10) - assert.is.equal(mapped_signal:get(), 20) -- Updated transformation - end) - - it('should handle empty transformations', function() - local test_signal = Signal:new(nil) - local mapped_signal = test_signal:map(function(value) return value or 'default' end) - - assert.is.equal(mapped_signal:get(), 'default') -- Return default - test_signal:set 'new value' - assert.is.equal(mapped_signal:get(), 'new value') -- Return new value - end) - end) - - describe('Signal:filter', function() - it('should only emit values that pass the filter', function() - local test_signal = Signal:new(5) - local filtered_signal = test_signal:filter(function(value) return value > 10 end) - - assert.is.equal(filtered_signal:get(), nil) -- Initial value should not pass - test_signal:set(15) - assert.is.equal(filtered_signal:get(), 15) -- Now filtered - test_signal:set(8) - assert.is.equal(filtered_signal:get(), 15) -- Does not pass the filter - end) - - it('should handle empty initial values', function() - local test_signal = Signal:new(nil) - local filtered_signal = test_signal:filter(function(value) return value ~= nil end) - - assert.is.equal(filtered_signal:get(), nil) -- Should be nil - test_signal:set(10) - assert.is.equal(filtered_signal:get(), 10) -- Should pass now - end) - end) - - describe('create_memo', function() - it('should compute a derived value and update when dependencies change', function() - local test_signal = Signal:new(2) - local memoized_signal = tracker.create_memo(function() return test_signal:get() * 2 end) - - assert.is.equal(memoized_signal:get(), 4) -- Initially compute 2 * 2 - - test_signal:set(3) - assert.is.equal(memoized_signal:get(), 6) -- Update to 3 * 2 = 6 - - test_signal:set(5) - assert.is.equal(memoized_signal:get(), 10) -- Update to 5 * 2 = 10 - end) - - it('should not recompute if the dependencies do not change', function() - local call_count = 0 - local test_signal = Signal:new(10) - local memoized_signal = tracker.create_memo(function() - call_count = call_count + 1 - return test_signal:get() + 1 - end) - - assert.is.equal(memoized_signal:get(), 11) -- Compute first value - assert.is.equal(call_count, 1) -- Should compute once - - memoized_signal:get() -- Call again, should use memoized value - assert.is.equal(call_count, 1) -- Still should only be one call - - test_signal:set(10) -- Set the same value - assert.is.equal(memoized_signal:get(), 11) - assert.is.equal(call_count, 2) - - test_signal:set(20) - assert.is.equal(memoized_signal:get(), 21) - assert.is.equal(call_count, 3) - end) - end) - - describe('create_effect', function() - it('should track changes and execute callback', function() - local test_signal = Signal:new(5) - local call_count = 0 - - tracker.create_effect(function() - test_signal:get() -- track as a dependency - call_count = call_count + 1 - end) - - assert.is.equal(call_count, 1) - test_signal:set(10) - assert.is.equal(call_count, 2) - end) - - it('should clean up signals and not call after dispose', function() - local test_signal = Signal:new(5) - local call_count = 0 - - local unsubscribe = tracker.create_effect(function() - call_count = call_count + 1 - return test_signal:get() * 2 - end) - - assert.is.equal(call_count, 1) -- Initially calls - unsubscribe() -- Unsubscribe the effect - test_signal:set(10) -- Update signal value - assert.is.equal(call_count, 1) -- Callback should not be called again - end) - end) -end) - -describe('ExecutionContext', function() - local context - - before_each(function() context = ExecutionContext:new() end) - - it('should initialize a new context', function() - assert.is.table(context.signals) - assert.is.table(context.subscribers) - end) - - it('should track signals', function() - local signal = Signal:new(0) - context:track(signal) - - assert.is.equal(next(context.signals), signal) -- Check if signal is tracked - end) - - it('should subscribe to signals', function() - local signal = Signal:new(0) - local callback_called = false - - context:track(signal) - context:subscribe(function() callback_called = true end) - - signal:set(100) - assert.is.equal(callback_called, true) -- Callback should be called - end) - - it('should dispose tracked signals', function() - local signal = Signal:new(0) - context:track(signal) - - context:dispose() - assert.is.falsy(next(context.signals)) -- Should not have any tracked signals - end) -end) diff --git a/u.nvim-0.2.0-1.rockspec b/u.nvim-0.2.0-1.rockspec index a2dfd80..41c2ff2 100644 --- a/u.nvim-0.2.0-1.rockspec +++ b/u.nvim-0.2.0-1.rockspec @@ -12,8 +12,6 @@ description = { build = { type = "builtin", modules = { - ["u.buffer"] = "lua/u/buffer.lua", - ["u.codewriter"] = "lua/u/codewriter.lua", ["u.opkeymap"] = "lua/u/opkeymap.lua", ["u.pos"] = "lua/u/pos.lua", ["u.range"] = "lua/u/range.lua",