Skip to content
no-vici edited this page May 26, 2024 · 24 revisions

ChoiceNode-Popup

showcase

local current_nsid = vim.api.nvim_create_namespace("LuaSnipChoiceListSelections")
local current_win = nil

local function window_for_choiceNode(choiceNode)
    local buf = vim.api.nvim_create_buf(false, true)
    local buf_text = {}
    local row_selection = 0
    local row_offset = 0
    local text
    for _, node in ipairs(choiceNode.choices) do
        text = node:get_docstring()
        -- find one that is currently showing
        if node == choiceNode.active_choice then
            -- current line is starter from buffer list which is length usually
            row_selection = #buf_text
            -- finding how many lines total within a choice selection
            row_offset = #text
        end
        vim.list_extend(buf_text, text)
    end

    vim.api.nvim_buf_set_text(buf, 0,0,0,0, buf_text)
    local w, h = vim.lsp.util._make_floating_popup_size(buf_text)

    -- adding highlight so we can see which one is been selected.
    local extmark = vim.api.nvim_buf_set_extmark(buf,current_nsid,row_selection ,0,
        {hl_group = 'incsearch',end_line = row_selection + row_offset})

    -- shows window at a beginning of choiceNode.
    local win = vim.api.nvim_open_win(buf, false, {
        relative = "win", width = w, height = h, bufpos = choiceNode.mark:pos_begin_end(), style = "minimal", border = 'rounded'})

    -- return with 3 main important so we can use them again
    return {win_id = win,extmark = extmark,buf = buf}
end

function choice_popup(choiceNode)
	-- build stack for nested choiceNodes.
	if current_win then
		vim.api.nvim_win_close(current_win.win_id, true)
                vim.api.nvim_buf_del_extmark(current_win.buf,current_nsid,current_win.extmark)
	end
        local create_win = window_for_choiceNode(choiceNode)
	current_win = {
		win_id = create_win.win_id,
		prev = current_win,
		node = choiceNode,
                extmark = create_win.extmark,
                buf = create_win.buf
	}
end

function update_choice_popup(choiceNode)
    vim.api.nvim_win_close(current_win.win_id, true)
    vim.api.nvim_buf_del_extmark(current_win.buf,current_nsid,current_win.extmark)
    local create_win = window_for_choiceNode(choiceNode)
    current_win.win_id = create_win.win_id
    current_win.extmark = create_win.extmark
    current_win.buf = create_win.buf
end

function choice_popup_close()
	vim.api.nvim_win_close(current_win.win_id, true)
        vim.api.nvim_buf_del_extmark(current_win.buf,current_nsid,current_win.extmark)
        -- now we are checking if we still have previous choice we were in after exit nested choice
	current_win = current_win.prev
	if current_win then
		-- reopen window further down in the stack.
                local create_win = window_for_choiceNode(current_win.node)
                current_win.win_id = create_win.win_id
                current_win.extmark = create_win.extmark
                current_win.buf = create_win.buf
	end
end

vim.cmd([[
augroup choice_popup
au!
au User LuasnipChoiceNodeEnter lua choice_popup(require("luasnip").session.event_node)
au User LuasnipChoiceNodeLeave lua choice_popup_close()
au User LuasnipChangeChoice lua update_choice_popup(require("luasnip").session.event_node)
augroup END
]])

This makes use of the nodeEnter/Leave/ChangeChoice events to show available choices. A similar effect can also be achieved by overriding the vim.ui.select-menu and binding select_choice to a key.

Improve Language Server-Snippets

This can be useful if a language server returns snippets that suffer from the limitations of lsp-snippets. In this particular case clangds snippets for member initializatizer lists always look like this: m_SomeMember(${0:m_SomeMembersType}). This is suboptimal in some ways, mainly that

  • Only normal parenthesis are possible, whereas curly braces may be preferred.
  • Luasnip treats the $0-placeholder as a one-time-stop, meaning that once SELECT is exited, there's no way to change it.

To fix this, we need to

  1. intercept and change the snippet received via LSP.
  2. override the expansion-function (here in nvim-cmp) to use our new snippet, if available.

We override the client.request-function so all responses from LSP go through our function. There we override the handler for "textDocument/completion" to modify the TextEdit returned by the language server. ("Inspired" by this comment).

local ls = require("luasnip")
local s = ls.snippet
local r = ls.restore_node
local i = ls.insert_node
local t = ls.text_node
local c = ls.choice_node

lspsnips = {}

nvim_lsp.clangd.setup{
	on_attach = function(client)
		local orig_rpc_request = client.rpc.request
		function client.rpc.request(method, params, handler, ...)
			local orig_handler = handler
			if method == 'textDocument/completion' then
				-- Idiotic take on <https://github.com/fannheyward/coc-pyright/blob/6a091180a076ec80b23d5fc46e4bc27d4e6b59fb/src/index.ts#L90-L107>.
				handler = function(...)
					local err, result = ...
					if not err and result then
						local items = result.items or result
						for _, item in ipairs(items) do
							-- override snippets for kind `field`, matching the snippets for member initializer lists.
							if item.kind == vim.lsp.protocol.CompletionItemKind.Field and
								item.textEdit.newText:match("^[%w_]+%(${%d+:[%w_]+}%)$") then

								local snip_text = item.textEdit.newText
								local name = snip_text:match("^[%w_]+")
								local type = snip_text:match("%{%d+:([%w_]+)%}")
								-- the snippet is stored in a separate table. It is not stored in the `item` passed to
								-- cmp, because it will be copied there and cmps [copy](https://github.com/hrsh7th/nvim-cmp/blob/ac476e05df2aab9f64cdd70b6eca0300785bb35d/lua/cmp/utils/misc.lua#L125-L143) doesn't account
								-- for self-referential tables and metatables (rightfully so, a response from lsp
								-- would contain neither), both of which are vital for a snippet.
								lspsnips[snip_text] = s("", {
									t(name),
									c(1, {
										-- use a restoreNode to remember the text typed here.
										{t"(", r(1, "type", i(1, type)), t")"},
										{t"{", r(1, "type"), t"}"},
									}, {restore_cursor = true})
								})
							end
						end
					end
					return orig_handler(...)
				end
			end
			return orig_rpc_request(method, params, handler, ...)
		end
	end
}

The last missing piece is changing the "default" snippet-expansion-function in cmp to account for our snippet:

cmp.setup {
	snippet = {
		expand = function(args)
			-- check if we created a snippet for this lsp-snippet.
			if lspsnips[args.body] then
				-- use `snip_expand` to expand the snippet at the cursor position.
				require("luasnip").snip_expand(lspsnips[args.body])
			else
				require("luasnip").lsp_expand(args.body)
			end
		end,
	},
}

et voilà: output

DynamicNode with user input

Normally, dynamicNodes can only update when text inside the snippet changed. This is pretty powerful, but not enough for eg. a latex-table-snippet, where the number of rows should be adjustable on-the-fly (otherwise a regex-triggered snippet with trig=tab(%d+)x(%d+) would suffice).
This isn't possible OOTB, so we need to write a function that

  1. Runs some other function whose output will be used in the dynamicNode-function.
  2. Updates the dynamicNode.

and then call that function using a mapping (optional, but much more comfortable than calling it manually).

local ls = require("luasnip")
local util = require("luasnip.util.util")
local node_util = require("luasnip.nodes.util")

local function find_dynamic_node(node)
	-- the dynamicNode-key is set on snippets generated by a dynamicNode only (its'
	-- actual use is to refer to the dynamicNode that generated the snippet).
	while not node.dynamicNode do
		node = node.parent
	end
	return node.dynamicNode
end

local external_update_id = 0
-- func_indx to update the dynamicNode with different functions.
function dynamic_node_external_update(func_indx)
	-- most of this function is about restoring the cursor to the correct
	-- position+mode, the important part are the few lines from
	-- `dynamic_node.snip:store()`.


	-- find current node and the innermost dynamicNode it is inside.
	local current_node = ls.session.current_nodes[vim.api.nvim_get_current_buf()]
	local dynamic_node = find_dynamic_node(current_node)

	-- to identify current node in new snippet, if it is available.
	external_update_id = external_update_id + 1
	current_node.external_update_id = external_update_id
	local current_node_key = current_node.key

	-- store which mode we're in to restore later.
	local insert_pre_call = vim.fn.mode() == "i"
	-- is byte-indexed! Doesn't matter here, but important to be aware of.
	local cursor_pos_end_relative = util.pos_sub(
		util.get_cursor_0ind(),
		current_node.mark:get_endpoint(1)
	)

	-- leave current generated snippet.
	node_util.leave_nodes_between(dynamic_node.snip, current_node)

	-- call update-function.
	local func = dynamic_node.user_args[func_indx]
	if func then
		-- the same snippet passed to the dynamicNode-function. Any output from func
		-- should be stored in it under some unused key.
		func(dynamic_node.parent.snippet)
	end

	-- last_args is used to store the last args that were used to generate the
	-- snippet. If this function is called, these will most probably not have
	-- changed, so they are set to nil, which will force an update.
	dynamic_node.last_args = nil
	dynamic_node:update()

	-- everything below here isn't strictly necessary, but it's pretty nice to have.


    -- try to find the node we marked earlier, or a node with the same key.
    -- Both are getting equal priority here, it might make sense to give "exact
    -- same node" higher priority by doing two searches (but that would require
    -- two searches :( )
	local target_node = dynamic_node:find_node(function(test_node)
		return (test_node.external_update_id == external_update_id) or (current_node_key ~= nil and test_node.key == current_node_key)
	end)

	if target_node then
		-- the node that the cursor was in when changeChoice was called exists
		-- in the active choice! Enter it and all nodes between it and this choiceNode,
		-- then set the cursor.
		node_util.enter_nodes_between(dynamic_node, target_node)

		if insert_pre_call then
			-- restore cursor-position if the node, or a corresponding node,
			-- could be found.
			-- It is restored relative to the end of the node (as opposed to the
			-- beginning). This does not matter if the text in the node is
			-- unchanged, but if the length changed, we may move the cursor
			-- relative to its immediate neighboring characters.
			-- I assume that it is more likely that the text before the cursor
			-- got longer (since it is very likely that the cursor is just at
			-- the end of the node), and thus restoring relative to the
			-- beginning would shift the cursor back.
			-- 
			-- However, restoring to any fixed endpoint is likely to not be
			-- perfect, an interesting enhancement would be to compare the new
			-- and old text/[neighborhood of the cursor], and find its new position
			-- based on that.
			util.set_cursor_0ind(
				util.pos_add(
					target_node.mark:get_endpoint(1),
					cursor_pos_end_relative
				)
			)
		else
			node_util.select_node(target_node)
		end
		-- set the new current node correctly.
		ls.session.current_nodes[vim.api.nvim_get_current_buf()] = target_node
	else
		-- the marked node wasn't found, just jump into the new snippet noremally.
		ls.session.current_nodes[vim.api.nvim_get_current_buf()] = dynamic_node.snip:jump_into(1)
	end
end

Bind the function to some key:

vim.api.nvim_set_keymap('i', "<C-t>", '<cmd>lua _G.dynamic_node_external_update(1)<Cr>', {noremap = true})
vim.api.nvim_set_keymap('s', "<C-t>", '<cmd>lua _G.dynamic_node_external_update(1)<Cr>', {noremap = true})

vim.api.nvim_set_keymap('i', "<C-g>", '<cmd>lua _G.dynamic_node_external_update(2)<Cr>', {noremap = true})
vim.api.nvim_set_keymap('s', "<C-g>", '<cmd>lua _G.dynamic_node_external_update(2)<Cr>', {noremap = true})

It may be useful to bind even more numbers (3-???????), but two suffice for this example.

Now it's time to make use of the new function:

local function column_count_from_string(descr)
	-- this won't work for all cases, but it's simple to improve
	-- (feel free to do so! :D )
	return #(descr:gsub("[^clm]", ""))
end

-- function for the dynamicNode.
local tab = function(args, snip)
	local cols = column_count_from_string(args[1][1])
	-- snip.rows will not be set by default, so handle that case.
	-- it's also the value set by the functions called from dynamic_node_external_update().
	if not snip.rows then
		snip.rows = 1
	end
	local nodes = {}
	-- keep track of which insert-index we're at.
	local ins_indx = 1
	for j = 1, snip.rows do
		-- use restoreNode to not lose content when updating.
		table.insert(nodes, r(ins_indx, tostring(j).."x1", i(1)))
		ins_indx = ins_indx+1
		for k = 2, cols do
			table.insert(nodes, t" & ")
			table.insert(nodes, r(ins_indx, tostring(j).."x"..tostring(k), i(1)))
			ins_indx = ins_indx+1
		end
		table.insert(nodes, t{"\\\\", ""})
	end
	-- fix last node.
	nodes[#nodes] = t""
	return sn(nil, nodes)
end


s("tab", fmt([[
\begin{{tabular}}{{{}}}
{}
\end{{tabular}}
]], {i(1, "c"), d(2, tab, {1}, {
	user_args = {
		-- Pass the functions used to manually update the dynamicNode as user args.
		-- The n-th of these functions will be called by dynamic_node_external_update(n).
		-- These functions are pretty simple, there's probably some cool stuff one could do
		-- with `ui.input`
		function(snip) snip.rows = snip.rows + 1 end,
		-- don't drop below one.
		function(snip) snip.rows = math.max(snip.rows - 1, 1) end
	}
} )}))

<C-t>, now calls the first function, increasing the number of rows, whereas <C-g> calls the second function, decreasing it.

And here's the result:
output

Conditional expansion for non-VimTeX users.

If you aren't using VimTeX or want to be independent from its in_mathzone() function, then you might find the below suggestion helpful. You can also hook it to your snippets for markdown as well, not just for .tex.

-- This module contains functions to check if the cursor is in a particular environment
local M = {}

-- Get the current line number and column
local function get_cursor_pos()
    local cursor = vim.api.nvim_win_get_cursor(0)
    -- vim API returns 0-based column, so we need to add 1 to column
    return cursor[1], cursor[2] + 1
end

-- Get the range of visible rows
local function get_visible_rows(current_row)
    -- Get the height of the current window
    local win_height = vim.api.nvim_win_get_height(0)

    -- Calculate the first and last visible rows
    local first_visible_row = math.max(1, current_row - math.floor(win_height / 2))
    local last_visible_row = math.min(vim.api.nvim_buf_line_count(0), current_row + math.ceil(win_height / 2))

    return first_visible_row, last_visible_row
end

-- Search for start and end patterns around the cursor
local function search_pair(env_name, current_row)
    -- Get the first and last visible rows
    local first_visible_row, last_visible_row = get_visible_rows(current_row)

    -- Define the start and end patterns
    local start_patterns = {
        "\\begin{" .. env_name .. "}",
        "\\start" .. env_name
    }
    local end_patterns = {
        "\\end{" .. env_name .. "}",
        "\\stop" .. env_name
    }

    local start_pos, end_pos = nil, nil

    -- Function to search a list of patterns in a specified range and direction
    local function search_patterns(patterns, from, to, step)
        for line_num = from, to, step do
            local line = vim.api.nvim_buf_get_lines(0, line_num - 1, line_num, false)[1]
            for _, pattern in ipairs(patterns) do
                if line:find(pattern) then
                    return line_num
                end
            end
        end
        return nil
    end

    -- Search upwards for the start pattern in the visible range
    start_pos = search_patterns(start_patterns, current_row, first_visible_row, -1)
    -- If the start pattern not found in the visible range, then search further up
    if not start_pos then
        start_pos = search_patterns(start_patterns, current_row, 1, -1)
    end

    -- Search downwards for the end pattern in the visible range
    end_pos = search_patterns(end_patterns, current_row, last_visible_row, 1)
    -- If the end pattern not found, then search further down
    if not end_pos then
        end_pos = search_patterns(end_patterns, current_row, vim.api.nvim_buf_line_count(0), 1)
    end

    return start_pos, end_pos
end

-- Check if the cursor is inside a given environment
local function in_env(env_name)
    local current_row, _ = get_cursor_pos()
    local start_pos, end_pos = search_pair(env_name, current_row)

    if start_pos and end_pos then
        return current_row >= start_pos and current_row <= end_pos
    end
    return false
end

-- Function to identify math zones in a line
local function find_math_zones(line)
    local math_patterns = {
        "%$%$.+%$%$", -- $$...$$
        "\\\\%[.+\\\\%]", -- \[...\]
        "\\\\%(.+\\\\%)", -- \( ... \)
        "%$[^$]+%$", -- inline math $...$
    }
    local math_zones = {}
    for _, pattern in ipairs(math_patterns) do
        local s, e = 1, 1
        while s do
            s, e = string.find(line, pattern, e)
            if s then
                table.insert(math_zones, {start = s, stop = e})
                e = e + 1
            end
        end
    end
    return math_zones -- = { { start = 1, stop = 16 }, { start = 18, stop = 33 }, ... }
end

-- Check if the cursor is inside a math zone
function M.is_mathzone()
    -- Get the cursor position
    local current_row, current_col = get_cursor_pos()

    -- Get the content of the current line
    local line = vim.api.nvim_buf_get_lines(0, current_row - 1, current_row, false)[1]

    -- Find the math zones in the line
    local math_zones = find_math_zones(line)

    for _, zone in ipairs(math_zones) do
        -- Check if the cursor is inside a math zone
        if current_col >= zone.start and current_col <= zone.stop then
            return true
        end
    end

    return false
end

-- Specific environment checks
M.in_text = function() return in_env("text") and not M.math_mode() end
M.in_math_range = function() return in_env("formula") end
M.in_tikz = function() return in_env("tikzpicture") end
M.in_bullets = function() return in_env("itemize") or in_env("enumerate") end
M.in_MPcode = function() return in_env("MPcode") end
M.math_mode = function() return M.is_mathzone() or M.in_math_range() end

-- Function to apply settings to math zones
-- Similar settings can be done for math ranges as well, but perhaps they might cause some distractions. 
local function apply_math_zone_settings()
    local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
    for i, line in ipairs(lines) do
        local math_zones = find_math_zones(line)
        for _, zone in ipairs(math_zones) do
            -- Apply specific settings or behaviours to the identified math zones
            vim.api.nvim_buf_add_highlight(0, -1, 'MathZone', i - 1, zone.start - 1, zone.stop)
        end
    end
end

-- Command to apply math zone settings
vim.api.nvim_create_user_command('ApplyMathZoneSettings', apply_math_zone_settings, {})

-- Highlight math zones with bright red
vim.cmd [[highlight MathZone guifg=#ff0000 gui=bold]]

-- Autocmd to apply math zone settings
vim.api.nvim_create_autocmd({"BufReadPost", "BufWritePost"}, {
    pattern = "*.tex",
    command = "ApplyMathZoneSettings"
})

-- Command to check if the cursor is in a math zone
-- vim.api.nvim_create_user_command('CheckCursorMathZone', function()
--     if M.is_mathzone() then
--         print("Cursor is in a math zone")
--     else
--         print("Cursor is NOT in a math zone")
--     end
-- end, {})

return M

Next, test it with:

    -- Assumming that you have a directory called `TEX` containing `conditions.lua` under the `lua` one.
    local tex = require("TEX.conditions")

    autosnippet({ trig='//', name='fraction', dscr="fraction (general)"},
    fmta([[
    \frac{<>}{<>} <>
    ]],
    { i(1), i(2), i(0) }),
    { condition = tex.math_mode }),

And in the following

   \begin{document} or \starttext

   \startformula or \begin{formula}
      {1}
   \stopformula or \end{formula}

   {2}
   ${3}$ {4} ${5}$
   \end{document} or \stoptext

the trigger // should be expanded to \frac{}{} at {1}, {3} and {5} but not at {2} or {4}.