range: extmarks/tsquery; renderer: text-change
All checks were successful
NeoVim tests / code-quality (push) Successful in 1m18s

This commit is contained in:
2025-06-11 20:04:46 -06:00
parent 12945a4cdf
commit 72b6886838
25 changed files with 1371 additions and 224 deletions

View File

@@ -2,10 +2,10 @@ local Range = require 'u.range'
local Renderer = require('u.renderer').Renderer
--- @class u.Buffer
--- @field bufnr number
--- @field bufnr integer
--- @field b vim.var_accessor
--- @field bo vim.bo
--- @field private renderer u.Renderer
--- @field renderer u.renderer.Renderer
local Buffer = {}
Buffer.__index = Buffer
@@ -68,13 +68,16 @@ function Buffer:motion(motion, opts)
return Range.from_motion(motion, opts)
end
--- @param event string|string[]
--- @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

View File

@@ -32,7 +32,7 @@ end
--- @param line string
--- @param bufnr? number
function CodeWriter.from_line(line, bufnr)
if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
local ws = line:match '^%s*'
local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr })

71
lua/u/extmark.lua Normal file
View File

@@ -0,0 +1,71 @@
local Pos = require 'u.pos'
---@class u.Extmark
---@field bufnr integer
---@field id integer
---@field nsid integer
local Extmark = {}
Extmark.__index = Extmark
--- @param bufnr integer
--- @param nsid integer
--- @param id integer
function Extmark.new(bufnr, nsid, id)
return setmetatable({
bufnr = bufnr,
nsid = nsid,
id = id,
}, Extmark)
end
--- @param range u.Range
--- @param nsid integer
function Extmark.from_range(range, nsid)
local r = range:to_charwise()
local stop = r.stop or r.start
local end_row = stop.lnum - 1
local end_col = stop.col
if range.mode == 'V' then
end_row = end_row + 1
end_col = 0
end
local id = vim.api.nvim_buf_set_extmark(r.start.bufnr, nsid, r.start.lnum - 1, r.start.col - 1, {
right_gravity = false,
end_right_gravity = true,
end_row = end_row,
end_col = end_col,
})
return Extmark.new(r.start.bufnr, nsid, id)
end
function Extmark:range()
local Range = require 'u.range'
local raw_extmark =
vim.api.nvim_buf_get_extmark_by_id(self.bufnr, self.nsid, self.id, { details = true })
local start_row0, start_col0, details = unpack(raw_extmark)
--- @type u.Pos
local start = Pos.from00(self.bufnr, start_row0, start_col0)
--- @type u.Pos?
local stop = details
and details.end_row
and details.end_col
and Pos.from01(self.bufnr, details.end_row, details.end_col)
local n_buf_lines = vim.api.nvim_buf_line_count(self.bufnr)
if stop and stop.lnum > n_buf_lines then
stop.lnum = n_buf_lines
stop = stop:eol()
end
if stop and stop.col == 0 then
stop.col = 1
stop = stop:next(-1)
end
return Range.new(start, stop, 'v')
end
function Extmark:delete() vim.api.nvim_buf_del_extmark(self.bufnr, self.nsid, self.id) end
return Extmark

View File

@@ -1,9 +1,9 @@
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(vim.fn.stdpath 'cache', 'u.log', name .. '.log.jsonl')
end
function M.file_for_name(name) return vim.fs.joinpath(LOG_ROOT, name .. '.log.jsonl') end
--------------------------------------------------------------------------------
-- Logger class
@@ -11,7 +11,6 @@ end
--- @class u.Logger
--- @field name string
--- @field private fd number
local Logger = {}
Logger.__index = Logger
M.Logger = Logger
@@ -20,10 +19,7 @@ M.Logger = Logger
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,
fd = (vim.uv or vim.loop).fs_open(file_path, 'a', tonumber('644', 8)),
}, Logger)
local self = setmetatable({ name = name }, Logger)
return self
end
@@ -32,10 +28,12 @@ end
function Logger:write(level, ...)
local data = { ... }
if #data == 1 then data = data[1] end
(vim.uv or vim.loop).fs_write(
self.fd,
vim.json.encode { ts = os.date(), level = level, data = data } .. '\n'
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
@@ -64,6 +62,12 @@ function M.setup()
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

View File

@@ -7,9 +7,9 @@ local function line_text(bufnr, lnum)
end
--- @class u.Pos
--- @field bufnr number buffer number
--- @field lnum number 1-based line index
--- @field col number 1-based column index
--- @field bufnr integer buffer number
--- @field lnum integer 1-based line index
--- @field col integer 1-based column index
--- @field off number
local Pos = {}
Pos.__index = Pos
@@ -31,16 +31,33 @@ end
function Pos.new(bufnr, lnum, col, off)
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
if off == nil then off = 0 end
local pos = {
--- @type u.Pos
return setmetatable({
bufnr = bufnr,
lnum = lnum,
col = col,
off = off,
}
setmetatable(pos, Pos)
return pos
}, Pos)
end
--- @param bufnr? number
--- @param lnum0 number 1-based
--- @param col0 number 1-based
--- @param off? number
function Pos.from00(bufnr, lnum0, col0, off) return Pos.new(bufnr, lnum0 + 1, col0 + 1, off) end
--- @param bufnr? number
--- @param lnum0 number 1-based
--- @param col1 number 1-based
--- @param off? number
function Pos.from01(bufnr, lnum0, col1, off) return Pos.new(bufnr, lnum0 + 1, col1, off) end
--- @param bufnr? number
--- @param lnum1 number 1-based
--- @param col0 number 1-based
--- @param off? number
function Pos.from10(bufnr, lnum1, col0, off) return Pos.new(bufnr, lnum1, col0 + 1, off) end
function Pos.invalid() return Pos.new(0, 0, 0, 0) end
function Pos.__lt(a, b) return a.lnum < b.lnum or (a.lnum == b.lnum and a.col < b.col) end
@@ -67,6 +84,15 @@ function Pos.__sub(x, y)
return x:next(-y)
end
--- @param bufnr number
--- @param lnum number
function Pos.from_eol(bufnr, lnum)
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
local pos = Pos.new(bufnr, lnum, 0)
pos.col = pos:line():len()
return pos
end
--- @param name string
--- @return u.Pos
function Pos.from_pos(name)
@@ -96,9 +122,16 @@ end
function Pos:as_vim() return { self.bufnr, self.lnum, self.col, self.off } end
function Pos:eol() return Pos.from_eol(self.bufnr, self.lnum) end
--- @param pos string
function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end
--- @param winnr? integer
function Pos:save_to_cursor(winnr)
vim.api.nvim_win_set_cursor(winnr or 0, { self.lnum, self.col - 1 })
end
--- @param mark string
function Pos:save_to_mark(mark)
local p = self:as_real()

View File

@@ -1,6 +1,8 @@
local Extmark = require 'u.extmark'
local Pos = require 'u.pos'
local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
local NS = vim.api.nvim_create_namespace 'u.range'
--- @class u.Range
--- @field start u.Pos
@@ -115,7 +117,7 @@ end
function Range.from_motion(motion, opts)
-- Options handling:
opts = opts or {}
if opts.bufnr == nil then opts.bufnr = vim.api.nvim_get_current_buf() end
if opts.bufnr == nil or opts.bufnr == 0 then opts.bufnr = vim.api.nvim_get_current_buf() end
if opts.contains_cursor == nil then opts.contains_cursor = false end
if opts.user_defined == nil then opts.user_defined = false end
@@ -146,6 +148,8 @@ function Range.from_motion(motion, opts)
_G.Range__from_motion_opfunc = function(ty)
_G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty)
end
local old_eventignore = vim.o.eventignore
vim.o.eventignore = 'all'
vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc'
vim.cmd {
cmd = 'normal',
@@ -153,6 +157,7 @@ function Range.from_motion(motion, opts)
args = { ESC .. 'g@' .. motion },
mods = { silent = true },
}
vim.o.eventignore = old_eventignore
end)
local captured_range = _G.Range__from_motion_opfunc_captured_range
@@ -193,6 +198,26 @@ function Range.from_motion(motion, opts)
return captured_range
end
--- @param opts? { contains_cursor?: boolean }
function Range.from_tsquery_caps(bufnr, query, opts)
opts = opts or { contains_cursor = true }
local ranges = Range.from_buf_text(bufnr):tsquery(query)
if not ranges then return end
if not opts.contains_cursor then return ranges end
local cursor = Pos.from_pos '.'
return vim.tbl_map(function(cap_ranges)
return vim
.iter(cap_ranges)
:filter(
--- @param r u.Range
function(r) return r:contains(cursor) end
)
:totable()
end, ranges)
end
--- Get range information from the currently selected visual text.
--- Note: from within a command mapping or an opfunc, use other specialized
--- utilities, such as:
@@ -220,19 +245,22 @@ end
--- @param args unknown
--- @return u.Range|nil
function Range.from_cmd_args(args)
--- @type 'v'|'V'
local mode
--- @type nil|u.Pos
local start
local stop
if args.range == 0 then
return nil
else
start = Pos.from_pos "'<"
stop = Pos.from_pos "'>"
mode = stop:is_col_max() and 'V' or 'v'
if args.range == 0 then return nil end
local bufnr = vim.api.nvim_get_current_buf()
if args.range == 1 then
return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line1, Pos.MAX_COL), 'V')
end
local is_visual = vim.fn.histget('cmd', -1):sub(1, 5) == [['<,'>]]
--- @type 'v'|'V'
local mode = is_visual and vim.fn.visualmode() or 'V'
if is_visual then
return Range.new(Pos.from_pos "'<", Pos.from_pos "'>", mode)
else
return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line2, Pos.MAX_COL), mode)
end
return Range.new(start, stop, mode)
end
function Range.find_nearest_brackets()
@@ -272,6 +300,13 @@ function Range:to_linewise()
return r
end
function Range:to_charwise()
local r = self:clone()
r.mode = 'v'
if r.stop:is_col_max() then r.stop = r.stop:as_real() end
return r
end
--- @param x u.Pos | u.Range
function Range:contains(x)
if getmetatable(x) == Pos then
@@ -313,27 +348,19 @@ end
--- @param left string
--- @param right string
function Range:save_to_pos(left, right)
if self:is_empty() then
self.start:save_to_pos(left)
self.start:save_to_pos(right)
else
self.start:save_to_pos(left)
self.stop:save_to_pos(right)
end
self.start:save_to_pos(left);
(self:is_empty() and self.start or self.stop):save_to_pos(right)
end
--- @param left string
--- @param right string
function Range:save_to_marks(left, right)
if self:is_empty() then
self.start:save_to_mark(left)
self.start:save_to_mark(right)
else
self.start:save_to_mark(left)
self.stop:save_to_mark(right)
end
self.start:save_to_mark(left);
(self:is_empty() and self.start or self.stop):save_to_mark(right)
end
function Range:save_to_extmark() return Extmark.from_range(self, NS) end
function Range:set_visual_selection()
if self:is_empty() then return end
if vim.api.nvim_get_current_buf() ~= self.start.bufnr then
@@ -348,14 +375,46 @@ function Range:set_visual_selection()
self.stop:save_to_pos '.'
end
--------------------------------------------------------------------------------
-- Range.from_* functions:
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Text access/manipulation utilities:
--------------------------------------------------------------------------------
--- @param query string
function Range:tsquery(query)
local bufnr = self.start.bufnr
local lang = vim.treesitter.language.get_lang(vim.bo[bufnr].filetype)
if lang == nil then return end
local parser = vim.treesitter.get_parser(bufnr, lang)
if parser == nil then return end
local tree = parser:parse()[1]
if tree == nil then return end
local root = tree:root()
local q = vim.treesitter.query.parse(lang, query)
--- @type table<string, u.Range[]>
local ranges = {}
for id, match, _meta in
q:iter_captures(root, bufnr, self.start.lnum - 1, (self.stop or self.start).lnum)
do
local start_row0, start_col0, stop_row0, stop_col0 = match:range()
local range = Range.new(
Pos.new(bufnr, start_row0 + 1, start_col0 + 1),
Pos.new(bufnr, stop_row0 + 1, stop_col0),
'v'
)
if range.stop.lnum > vim.api.nvim_buf_line_count(bufnr) then
range.stop = range.stop:must_next(-1)
end
local capture_name = q.captures[id]
if not ranges[capture_name] then ranges[capture_name] = {} end
if self:contains(range) then table.insert(ranges[capture_name], range) end
end
return ranges
end
function Range:length()
if self:is_empty() then return 0 end

View File

@@ -1,12 +1,33 @@
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.Tag { kind: 'tag'; name: string, attributes: table<string, unknown>, children: u.renderer.Tree }
--- @alias u.renderer.TagEventHandler fun(tag: u.renderer.Tag, mode: string, lhs: string): string
--- @alias u.renderer.TagAttributes { [string]?: unknown; imap?: table<string, u.renderer.TagEventHandler>; nmap?: table<string, u.renderer.TagEventHandler>; vmap?: table<string, u.renderer.TagEventHandler>; xmap?: table<string, u.renderer.TagEventHandler>; omap?: table<string, u.renderer.TagEventHandler>, on_change?: fun(text: string): unknown }
--- @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<string, fun(attributes: table<string, any>, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: table<string, any>, children: u.renderer.Tree): u.renderer.Tag>
--- @type table<string, fun(attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag>
M.h = setmetatable({}, {
__call = function(_, name, attributes, children)
return {
@@ -17,7 +38,6 @@ M.h = setmetatable({}, {
}
end,
__index = function(_, name)
-- vim.print('dynamic hl ' .. name)
return function(attributes, children)
return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children)
end
@@ -25,14 +45,19 @@ M.h = setmetatable({}, {
})
-- Renderer {{{
--- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any }
--- @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
--- @class u.renderer.Renderer
--- @field bufnr number
--- @field ns number
--- @field changedtick number
--- @field old { lines: string[]; extmarks: RendererExtmark[] }
--- @field curr { lines: string[]; extmarks: RendererExtmark[] }
--- @field old { lines: string[]; extmarks: u.renderer.RendererExtmark[] }
--- @field curr { lines: string[]; extmarks: u.renderer.RendererExtmark[] }
local Renderer = {}
Renderer.__index = Renderer
M.Renderer = Renderer
@@ -51,7 +76,7 @@ function Renderer.is_tag_arr(x)
end
--- @param bufnr number|nil
function Renderer.new(bufnr) -- {{{
if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end
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))
@@ -64,6 +89,12 @@ function Renderer.new(bufnr) -- {{{
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 -- }}}
@@ -128,7 +159,7 @@ function Renderer.markup_to_lines(opts) -- {{{
end -- }}}
--- @param opts {
--- tree: string;
--- tree: u.renderer.Tree;
--- format_tag?: fun(tag: u.renderer.Tag): string;
--- }
function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end
@@ -136,7 +167,7 @@ function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_
--- @param bufnr number
--- @param old_lines string[] | nil
--- @param new_lines string[]
function Renderer.patch_lines(bufnr, old_lines, new_lines)
function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{
--
-- Helpers:
--
@@ -189,7 +220,7 @@ function Renderer.patch_lines(bufnr, old_lines, new_lines)
-- No change
end
end
end
end -- }}}
--- @param tree u.renderer.Tree
function Renderer:render(tree) -- {{{
@@ -199,7 +230,7 @@ function Renderer:render(tree) -- {{{
self.changedtick = changedtick
end
--- @type RendererExtmark[]
--- @type u.renderer.RendererExtmark[]
local extmarks = {}
--- @type string[]
@@ -214,31 +245,40 @@ function Renderer:render(tree) -- {{{
tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or hl
end
local extmark = tag.attributes.extmark
local extmark_opts = tag.attributes.extmark 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:
extmark = extmark or {}
vim.keymap.set(
'n',
lhs,
function() return self:_expr_map_callback('n', lhs) end,
{ buffer = self.bufnr, expr = true, replace_keycodes = true }
)
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 '<Nop>' (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
if extmark then
table.insert(extmarks, {
start = start0,
stop = stop0,
opts = extmark,
tag = tag,
})
end
table.insert(extmarks, {
start = start0,
stop = stop0,
opts = extmark_opts,
tag = tag,
})
end
end, -- }}}
}
@@ -246,27 +286,9 @@ function Renderer:render(tree) -- {{{
self.old = self.curr
self.curr = { lines = lines, extmarks = extmarks }
self:_reconcile()
vim.cmd.doautocmd { args = { 'User', 'Renderer:' .. tostring(self.bufnr) .. ':render' } }
end -- }}}
--- @private
--- @param start integer
--- @param end_ integer
--- @param strict_indexing boolean
--- @param replacement string[]
function Renderer:_set_lines(start, end_, strict_indexing, replacement)
vim.api.nvim_buf_set_lines(self.bufnr, start, end_, strict_indexing, replacement)
end
--- @private
--- @param start_row integer
--- @param start_col integer
--- @param end_row integer
--- @param end_col integer
--- @param replacement string[]
function Renderer:_set_text(start_row, start_col, end_row, end_col, replacement)
vim.api.nvim_buf_set_text(self.bufnr, start_row, start_col, end_row, end_col, replacement)
end
--- @private
function Renderer:_reconcile() -- {{{
--
@@ -277,10 +299,15 @@ function Renderer:_reconcile() -- {{{
--
-- 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(
@@ -292,6 +319,13 @@ function Renderer:_reconcile() -- {{{
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
@@ -306,7 +340,9 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
-- find the tag with the smallest intersection that contains the cursor:
local pos0 = vim.api.nvim_win_get_cursor(0)
pos0[1] = pos0[1] - 1 -- make it actually 0-based
local pos_infos = self:get_pos_infos(pos0)
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
@@ -316,9 +352,10 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
local tag = pos_info.tag
-- is the tag listening?
--- @type u.renderer.TagEventHandler?
local f = vim.tbl_get(tag.attributes, mode .. 'map', lhs)
if type(f) == 'function' then
local result = f()
local result = f(tag, mode, lhs)
if result == '' then
-- bubble-up to the next tag, but set cancel to true, in case there are
-- no more tags to bubble up to:
@@ -333,31 +370,197 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
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]
--- @return { extmark: RendererExtmark; tag: u.renderer.Tag; }[]
function Renderer:get_pos_infos(pos0) -- {{{
--- @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 RendererExtmark[]
local intersecting_extmarks = vim
.iter(
vim.api.nvim_buf_get_extmarks(
self.bufnr,
self.ns,
pos0,
pos0,
{ details = true, overlap = true }
)
)
--- @return RendererExtmark
--- @type u.renderer.RendererExtmark[]
local mapped_extmarks = vim
.iter(raw_overlapping_extmarks)
--- @return u.renderer.RendererExtmark
:map(function(ext)
--- @type number, number, number, { end_row?: number; end_col?: number }|nil
local id, line0, col0, details = unpack(ext)
@@ -368,13 +571,43 @@ function Renderer:get_pos_infos(pos0) -- {{{
end
return { id = id, start = start, stop = stop, opts = details }
end)
--- @param ext RendererExtmark
:totable()
local intersecting_extmarks = vim
.iter(mapped_extmarks)
--- @param ext u.renderer.RendererExtmark
:filter(function(ext)
if ext.stop[1] ~= nil and ext.stop[2] ~= nil then
return cursor_line0 >= ext.start[1]
and cursor_col0 >= ext.start[2]
and cursor_line0 <= ext.stop[1]
and cursor_col0 < ext.stop[2]
-- 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
@@ -384,8 +617,8 @@ function Renderer:get_pos_infos(pos0) -- {{{
-- Sort the tags into smallest (inner) to largest (outer):
table.sort(
intersecting_extmarks,
--- @param x1 RendererExtmark
--- @param x2 RendererExtmark
--- @param x1 u.renderer.RendererExtmark
--- @param x2 u.renderer.RendererExtmark
function(x1, x2)
if
x1.start[1] == x2.start[1]
@@ -407,10 +640,10 @@ function Renderer:get_pos_infos(pos0) -- {{{
-- created extmarks in self.curr.extmarks, which also has which tag each
-- extmark is associated with. Cross-reference with that list to get a list
-- of tags that we need to fire events for:
--- @type { extmark: RendererExtmark; tag: u.renderer.Tag }[]
--- @type { extmark: u.renderer.RendererExtmark; tag: u.renderer.Tag }[]
local matching_tags = vim
.iter(intersecting_extmarks)
--- @param ext RendererExtmark
--- @param ext u.renderer.RendererExtmark
:map(function(ext)
for _, extmark_cache in ipairs(self.curr.extmarks) do
if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end
@@ -420,10 +653,23 @@ function Renderer:get_pos_infos(pos0) -- {{{
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.TreeBuilder
--- @class u.renderer.TreeBuilder
--- @field private nodes u.renderer.Node[]
local TreeBuilder = {}
TreeBuilder.__index = TreeBuilder
@@ -435,7 +681,7 @@ function TreeBuilder.new()
end
--- @param nodes u.renderer.Tree
--- @return u.TreeBuilder
--- @return u.renderer.TreeBuilder
function TreeBuilder:put(nodes)
table.insert(self.nodes, nodes)
return self
@@ -444,15 +690,15 @@ end
--- @param name string
--- @param attributes? table<string, any>
--- @param children? u.renderer.Node | u.renderer.Node[]
--- @return u.TreeBuilder
--- @return u.renderer.TreeBuilder
function TreeBuilder:put_h(name, attributes, children)
local tag = M.h(name, attributes, children)
table.insert(self.nodes, tag)
return self
end
--- @param fn fun(TreeBuilder): any
--- @return u.TreeBuilder
--- @param fn fun(tb: u.renderer.TreeBuilder): any
--- @return u.renderer.TreeBuilder
function TreeBuilder:nest(fn)
local nested_writer = TreeBuilder.new()
fn(nested_writer)
@@ -460,19 +706,40 @@ function TreeBuilder:nest(fn)
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 LevenshteinChange<T> ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; })
--- @alias u.renderer.LevenshteinChange<T> ({ kind: 'add'; item: T; index: 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 LevenshteinChange<T>[] The changes, from last (greatest index) to first (smallest index).
--- @return u.renderer.LevenshteinChange<T>[] The changes, from last (greatest index) to first (smallest index).
function H.levenshtein(x, y, cost)
-- 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
@@ -523,7 +790,7 @@ function H.levenshtein(x, y, cost)
-- Backtrack to find the changes
local i = m
local j = n
--- @type LevenshteinChange[]
--- @type u.renderer.LevenshteinChange[]
local changes = {}
while i > 0 or j > 0 do
@@ -540,7 +807,7 @@ function H.levenshtein(x, y, cost)
if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
-- potential change
if x[i] ~= y[j] then
--- @type LevenshteinChange
--- @type u.renderer.LevenshteinChange
local change = { kind = 'change', from = x[i], index = i, to = y[j] }
table.insert(changes, change)
end
@@ -548,13 +815,13 @@ function H.levenshtein(x, y, cost)
j = j - 1
elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
-- addition
--- @type LevenshteinChange
--- @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 LevenshteinChange
--- @type u.renderer.LevenshteinChange
local change = { kind = 'delete', item = x[i], index = i }
table.insert(changes, change)
i = i - 1

View File

@@ -6,19 +6,19 @@ M.debug = false
-- class Signal
--------------------------------------------------------------------------------
--- @class u.Signal
--- @class u.Signal<T>
--- @field name? string
--- @field private changing boolean
--- @field private value any
--- @field private value T
--- @field private subscribers table<function, boolean>
--- @field private on_dispose_callbacks function[]
local Signal = {}
M.Signal = Signal
Signal.__index = Signal
--- @param value any
--- @param value `T`
--- @param name? string
--- @return u.Signal
--- @return u.Signal<T>
function Signal:new(value, name)
local obj = setmetatable({
name = name,
@@ -30,7 +30,7 @@ function Signal:new(value, name)
return obj
end
--- @param value any
--- @param value T
function Signal:set(value)
self.value = value
@@ -67,11 +67,12 @@ function Signal:set(value)
end
end
--- @param value T
function Signal:schedule_set(value)
vim.schedule(function() self:set(value) end)
end
--- @return any
--- @return T
function Signal:get()
local ctx = M.ExecutionContext.current()
if ctx then ctx:track(self) end
@@ -85,8 +86,8 @@ function Signal:update(fn) self:set(fn(self.value)) end
function Signal:schedule_update(fn) self:schedule_set(fn(self.value)) end
--- @generic U
--- @param fn fun(value: T): U
--- @return u.Signal --<U>
--- @param fn fun(value: T): `U`
--- @return u.Signal<U>
function Signal:map(fn)
local mapped_signal = M.create_memo(function()
local value = self:get()
@@ -95,13 +96,13 @@ function Signal:map(fn)
return mapped_signal
end
--- @return u.Signal
--- @return u.Signal<T>
function Signal:clone()
return self:map(function(x) return x end)
end
--- @param fn fun(value: T): boolean
--- @return u.Signal -- <T>
--- @return u.Signal<T>
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)
@@ -112,10 +113,10 @@ function Signal:filter(fn)
end
--- @param ms number
--- @return u.Signal -- <T>
--- @return u.Signal<T>
function Signal:debounce(ms)
local function set_timeout(timeout, callback)
local timer = (vim.uv or vim.loop).new_timer()
local timer = assert((vim.uv or vim.loop).new_timer(), 'could not create new timer')
timer:start(timeout, 0, function()
timer:stop()
timer:close()
@@ -127,7 +128,7 @@ function Signal:debounce(ms)
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_timer_t; }
--- @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
@@ -202,6 +203,7 @@ end
-- class ExecutionContext
--------------------------------------------------------------------------------
--- @type u.ExecutionContext|nil
local CURRENT_CONTEXT = nil
--- @class u.ExecutionContext
@@ -262,16 +264,18 @@ end
-- Helpers
--------------------------------------------------------------------------------
--- @param value any
--- @generic T
--- @param value `T`
--- @param name? string
--- @return u.Signal
--- @return u.Signal<T>
function M.create_signal(value, name) return Signal:new(value, name) end
--- @param fn function
--- @generic T
--- @param fn fun(): `T`
--- @param name? string
--- @return u.Signal
function M.create_memo(fn, name)
--- @type u.Signal
--- @type u.Signal<T> | nil
local result
local unsubscribe = M.create_effect(function()
local value = fn()
@@ -282,8 +286,8 @@ function M.create_memo(fn, name)
result = M.create_signal(value, name and ('m.s:' .. name) or nil)
end
end, name)
result:on_dispose(unsubscribe)
return result
assert(result):on_dispose(unsubscribe)
return assert(result)
end
--- @param fn function

View File

@@ -4,15 +4,63 @@ local M = {}
-- Types
--
--- @alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string }
--- @alias KeyMaps table<string, fun(): any | string> }
-- luacheck: ignore
--- @alias CmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any; info: u.Range|nil }
--- @class u.utils.QfItem
--- @field col number
--- @field filename string
--- @field kind string
--- @field lnum number
--- @field text string
--- @class u.utils.RawCmdArgs
--- @field args string
--- @field bang boolean
--- @field count number
--- @field fargs string[]
--- @field line1 number
--- @field line2 number
--- @field mods string
--- @field name string
--- @field range 0|1|2
--- @field reg string
--- @field smods any
--- @class u.utils.CmdArgs: u.utils.RawCmdArgs
--- @field info u.Range|nil
--- @class u.utils.UcmdArgs
--- @field nargs? 0|1|'*'|'?'|'+'
--- @field range? boolean|'%'|number
--- @field count? boolean|number
--- @field addr? string
--- @field completion? string
--- @field force? boolean
--- @field preview? fun(opts: u.utils.UcmdArgs, ns: integer, buf: integer):0|1|2
--
-- Functions
--
--- Debug utility that prints a value and returns it unchanged.
--- Useful for debugging in the middle of expressions or function chains.
---
--- @generic T
--- @param x `T`
--- @param message? string
--- @return T
--- @param x `T` The value to debug print
--- @param message? string Optional message to print alongside the value
--- @return T The original value, unchanged
---
--- @usage
--- ```lua
--- -- Debug a value in the middle of a chain:
--- local result = some_function()
--- :map(utils.dbg) -- prints the intermediate value
--- :filter(predicate)
---
--- -- Debug with a custom message:
--- local config = utils.dbg(get_config(), "Current config:")
---
--- -- Debug return values:
--- return utils.dbg(calculate_result(), "Final result")
--- ```
function M.dbg(x, message)
local t = {}
if message ~= nil then table.insert(t, message) end
@@ -21,22 +69,37 @@ function M.dbg(x, message)
return x
end
--- A utility for creating user commands that also pre-computes useful information
--- and attaches it to the arguments.
--- Creates a user command with enhanced argument processing.
--- Automatically computes range information and attaches it as `args.info`.
---
--- @param name string The command name (without the leading colon)
--- @param cmd string | fun(args: u.utils.CmdArgs): any Command implementation
--- @param opts? u.utils.UcmdArgs Command options (nargs, range, etc.)
---
--- @usage
--- ```lua
--- -- Example:
--- ucmd('MyCmd', function(args)
--- -- print the visually selected text:
--- -- Create a command that works with visual selections:
--- utils.ucmd('MyCmd', function(args)
--- -- Print the visually selected text:
--- vim.print(args.info:text())
--- -- or get the vtext as an array of lines:
--- -- Or get the selection as an array of lines:
--- vim.print(args.info:lines())
--- end, { nargs = '*', range = true })
---
--- -- Create a command that processes the current line:
--- utils.ucmd('ProcessLine', function(args)
--- local line_text = args.info:text()
--- -- Process the line...
--- end, { range = '%' })
---
--- -- Create a command with arguments:
--- utils.ucmd('SearchReplace', function(args)
--- local pattern, replacement = args.fargs[1], args.fargs[2]
--- local text = args.info:text()
--- -- Perform search and replace...
--- end, { nargs = 2, range = true })
--- ```
--- @param name string
--- @param cmd string | fun(args: CmdArgs): any
-- luacheck: ignore
--- @param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string }
function M.ucmd(name, cmd, opts)
local Range = require 'u.range'
@@ -48,9 +111,81 @@ function M.ucmd(name, cmd, opts)
return cmd(args)
end
end
vim.api.nvim_create_user_command(name, cmd2, opts or {})
vim.api.nvim_create_user_command(name, cmd2, opts or {} --[[@as any]])
end
--- Creates command arguments for delegating from one command to another.
--- Preserves all relevant context (range, modifiers, bang, etc.) when
--- implementing a derived command in terms of a base command.
---
--- @param current_args vim.api.keyset.create_user_command.command_args|u.utils.RawCmdArgs The arguments from the current command
--- @return vim.api.keyset.cmd Arguments suitable for vim.cmd() calls
---
--- @usage
--- ```lua
--- -- Implement :MyEdit in terms of :edit, preserving all context:
--- utils.ucmd('MyEdit', function(args)
--- local delegated_args = utils.create_delegated_cmd_args(args)
--- -- Add custom logic here...
--- vim.cmd.edit(delegated_args)
--- end, { nargs = '*', range = true, bang = true })
---
--- -- Implement :MySubstitute that delegates to :substitute:
--- utils.ucmd('MySubstitute', function(args)
--- -- Pre-process arguments
--- local pattern = preprocess_pattern(args.fargs[1])
---
--- local delegated_args = utils.create_delegated_cmd_args(args)
--- delegated_args.args = { pattern, args.fargs[2] }
---
--- vim.cmd.substitute(delegated_args)
--- end, { nargs = 2, range = true, bang = true })
--- ```
function M.create_delegated_cmd_args(current_args)
--- @type vim.api.keyset.cmd
local args = {
range = current_args.range == 1 and { current_args.line1 }
or current_args.range == 2 and { current_args.line1, current_args.line2 }
or nil,
count = (current_args.count ~= -1 and current_args.range == 0) and current_args.count or nil,
reg = current_args.reg ~= '' and current_args.reg or nil,
bang = current_args.bang or nil,
args = #current_args.fargs > 0 and current_args.fargs or nil,
mods = current_args.smods,
}
return args
end
--- Gets the current editor dimensions.
--- Useful for positioning floating windows or calculating layout sizes.
---
--- @return { width: number, height: number } The editor dimensions in columns and lines
---
--- @usage
--- ```lua
--- -- Center a floating window:
--- local dims = utils.get_editor_dimensions()
--- local win_width = 80
--- local win_height = 20
--- local col = math.floor((dims.width - win_width) / 2)
--- local row = math.floor((dims.height - win_height) / 2)
---
--- vim.api.nvim_open_win(bufnr, true, {
--- relative = 'editor',
--- width = win_width,
--- height = win_height,
--- col = col,
--- row = row,
--- })
---
--- -- Check if editor is wide enough for side-by-side layout:
--- local dims = utils.get_editor_dimensions()
--- if dims.width >= 160 then
--- -- Use side-by-side layout
--- else
--- -- Use stacked layout
--- end
--- ```
function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end
return M