Files
Justin M. Keyes 55ceb314ca feat(ui): use vim.ui.select for :tselect, z= #39478
Problem:
`:tselect` and `z=` (spell suggest) have their own bespoke select menus.

Solution:
- Delegate to `vim.ui.select` instead.
- Bonus:
  - `:tselect` gains mouse support. `print_tag_list` didn't suport mouseclick.

This causes some minor regressions, which are not blockers:

- `z=` no longer draws the list right-left if 'rightleft' is set.
  - TODO: can/should `vim.ui.select` / `vim.fn.inputlist()` handle that?
- `:tselect`
  - No "column" headings (`# pri kind tag file`).
  - No highlighting: (HLF_T: tag name, HLF_D: file, HLF_CM: extra fields).
  - TODO: can `vim.ui.select()` support highlighted chunks (`[[text, hl_id], ...]`) ?

fix https://github.com/neovim/neovim/issues/25814
fix https://github.com/neovim/neovim/issues/31987
2026-04-28 18:29:17 -04:00

396 lines
13 KiB
Lua

local M = vim._defer_require('vim.ui', {
img = ..., --- @module 'vim.ui.img'
})
---@class vim.ui.select.Opts
---@inlinedoc
---
--- Prompt text.
--- (default: `"Select one of:"`)
---@field prompt? string
---
--- Decides how to format items when displayed in the picker.
--- (default: `tostring`)
---@field format_item? fun(item: any):string
---
--- Decides how to preview an item by preparing a scratch buffer with the item details (including text, highlighting, etc.).
--- When the picker decides to "preview" an item, it should call this function.
--- Must return a table with these keys:
--- - {buf}? (`integer`) Buffer containing the previewed item details, or nil if no preview should be shown.
--- - {pos}? (`[integer, integer]`) Specifies the (1,0)-indexed cursor position in the preview buffer. If nil, should be treated as `{ 1, 0 }`.
--- - {pos_end}? (`[integer, integer]`) Specifies the (1,0)-indexed (end-exclusive) end position of the preview range. If nil, no range is intended for a preview.
---@field preview_item? fun(item: any):{buf?:integer, pos?:[integer,integer], pos_end?:[integer,integer]}
---
--- Arbitrary hint string indicating the item shape. The picker may wish to use this to infer the
--- structure or semantics of `items`, or the context in which select() was called.
---@field kind? string
--- Prompts the user to pick from a list of items, allowing arbitrary (potentially asynchronous)
--- work until `on_choice`. This is the standard "picker" interface, used by |z=|, |:tselect|, etc.
---
--- Plugins may override `vim.ui.select` to provide a custom picker; they are expected to call the
--- `format_item` and `preview_item` handlers (if any) provided by the caller. They may also use the
--- `kind` hint (if provided by the caller) to decide how to handle some items.
---
--- Note: the default `vim.ui.select` currently doesn't support preview.
---
--- Example:
---
--- ```lua
--- vim.ui.select({ 'tabs', 'spaces' }, {
--- prompt = 'Select tabs or spaces:',
--- format_item = function(item)
--- return ('I choose %s!'):format(item)
--- end,
--- preview_item = function(item)
--- local lines = { 'This is ' .. vim.inspect(item) }
--- local buf = vim.api.nvim_create_buf(false, true)
--- vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
--- vim.bo[buf].bufhidden = 'wipe'
--- return { buf = buf }
--- end,
--- }, function(choice)
--- vim.o.expandtab = choice == 'spaces'
--- vim.print(('Selected "%s" => expandtab=%s'):format(choice, vim.o.expandtab))
--- end)
--- ```
---
---@generic T
---@param items T[] Arbitrary items
---@param opts vim.ui.select.Opts Options
---@param on_choice fun(item: T|nil, idx: integer|nil) Called once the user made a choice.
--- `idx` is the 1-based index of `item` within `items`, or `nil` if the user aborted the dialog.
function M.select(items, opts, on_choice)
vim.validate('items', items, 'table')
vim.validate('on_choice', on_choice, 'function')
opts = opts or {}
local choices = { opts.prompt or 'Select one of:' }
local format_item = opts.format_item or tostring
for i, item in
ipairs(items --[[@as any[] ]])
do
table.insert(choices, string.format('%d: %s', i, format_item(item)))
end
local choice = vim.fn.inputlist(choices)
if choice < 1 or choice > #items then
on_choice(nil, nil)
else
on_choice(items[choice], choice)
end
end
---@class vim.ui.input.Opts
---@inlinedoc
---
---Text of the prompt
---@field prompt? string
---
---Default reply to the input
---@field default? string
---
---Specifies type of completion supported
---for input. Supported types are the same
---that can be supplied to a user-defined
---command using the "-complete=" argument.
---See |:command-completion|
---@field completion? string
---
---Function that will be used for highlighting
---user inputs.
---@field highlight? function
--- Prompts the user for input, allowing arbitrary (potentially asynchronous) work until
--- `on_confirm`.
---
--- Example:
---
--- ```lua
--- vim.ui.input({ prompt = 'Enter value for shiftwidth: ' }, function(input)
--- vim.o.shiftwidth = tonumber(input)
--- end)
--- ```
---
---@param opts? vim.ui.input.Opts Additional options. See |input()|
---@param on_confirm fun(input?: string)
--- Called once the user confirms or abort the input.
--- `input` is what the user typed (it might be
--- an empty string if nothing was entered), or
--- `nil` if the user aborted the dialog.
function M.input(opts, on_confirm)
vim.validate('opts', opts, 'table', true)
vim.validate('on_confirm', on_confirm, 'function')
opts = (opts and not vim.tbl_isempty(opts)) and opts or vim.empty_dict()
-- Note that vim.fn.input({}) returns an empty string when cancelled.
-- vim.ui.input() should distinguish aborting from entering an empty string.
local _canceled = vim.NIL
opts = vim.tbl_extend('keep', opts, { cancelreturn = _canceled })
local ok, input = pcall(vim.fn.input, opts)
if not ok or input == _canceled then
on_confirm(nil)
else
on_confirm(input)
end
end
---@class vim.ui.open.Opts
---@inlinedoc
---
--- Command used to open the path or URL.
---@field cmd? string[]
--- Opens `path` with the system default handler (macOS `open`, Windows `explorer.exe`, Linux
--- `xdg-open`, …), or returns (but does not show) an error message on failure.
---
--- Can also be invoked with `:Open`. [:Open]()
---
--- Expands "~/" and environment variables in filesystem paths.
---
--- Examples:
---
--- ```lua
--- -- Asynchronous.
--- vim.ui.open("https://neovim.io/")
--- vim.ui.open("~/path/to/file")
--- -- Use the "osurl" command to handle the path or URL.
--- vim.ui.open("gh#neovim/neovim!29490", { cmd = { 'osurl' } })
--- -- Synchronous (wait until the process exits).
--- local cmd, err = vim.ui.open("$VIMRUNTIME")
--- if cmd then
--- cmd:wait()
--- end
--- ```
---
---@param path string Path or URL to open
---@param opt? vim.ui.open.Opts Options
---
---@return vim.SystemObj|nil # Command object, or nil if not found.
---@return nil|string # Error message on failure, or nil on success.
---
---@see |vim.system()|
function M.open(path, opt)
vim.validate('path', path, 'string')
local is_uri = path:match('%w+:')
if not is_uri then
path = vim.fs.normalize(path)
end
opt = opt or {}
local cmd ---@type string[]
local job_opt = { text = true, detach = true } --- @type vim.SystemOpts
if opt.cmd then
cmd = vim.list_extend(opt.cmd --[[@as string[] ]], { path })
else
local open_cmd, err = M._get_open_cmd()
if err then
return nil, err
end
---@cast open_cmd string[]
if open_cmd[1] == 'xdg-open' then
job_opt.stdout = false
job_opt.stderr = false
end
cmd = vim.list_extend(open_cmd, { path })
end
return vim.system(cmd, job_opt), nil
end
--- Get an available command used to open the path or URL.
---
--- @return string[]|nil # Command, or nil if not found.
--- @return nil|string # Error message on failure, or nil on success.
function M._get_open_cmd()
if vim.fn.has('mac') == 1 then
return { 'open' }, nil
elseif vim.fn.has('win32') == 1 then
return { 'cmd.exe', '/c', 'start', '' }, nil
elseif vim.fn.executable('xdg-open') == 1 then
return { 'xdg-open' }, nil
elseif vim.fn.executable('wslview') == 1 then
return { 'wslview' }, nil
elseif vim.fn.executable('explorer.exe') == 1 then
return { 'explorer.exe' }, nil
elseif vim.fn.executable('lemonade') == 1 then
return { 'lemonade', 'open' }, nil
else
return nil, 'vim.ui.open: no handler found (tried: wslview, explorer.exe, xdg-open, lemonade)'
end
end
--- @param bufnr integer
local get_lsp_urls = function(bufnr)
local has_lsp_support = false
for _, client in pairs(vim.lsp.get_clients({ bufnr = bufnr })) do
has_lsp_support = has_lsp_support or client:supports_method('textDocument/documentLink', bufnr)
end
if not has_lsp_support then
return {}
end
local params = { textDocument = vim.lsp.util.make_text_document_params(bufnr) }
local results = vim.lsp.buf_request_sync(bufnr, 'textDocument/documentLink', params)
local urls = {}
for client_id, result in pairs(results or {}) do
if result.error then
vim.lsp.log.error(result.error)
else
local client = assert(vim.lsp.get_client_by_id(client_id))
local lsp_position = vim.lsp.util.make_position_params(0, client.offset_encoding).position
local position = vim.pos.lsp(bufnr, lsp_position, client.offset_encoding)
local document_links = result.result or {} ---@type lsp.DocumentLink[]
for _, document_link in ipairs(document_links) do
local range = vim.range.lsp(bufnr, document_link.range, client.offset_encoding)
if document_link.target and range:has(position) then
local target = document_link.target ---@type string
if vim.startswith(target, 'file://') then
target = vim.uri_to_fname(target)
end
table.insert(urls, target)
end
end
end
end
return urls
end
--- Returns all URLs at cursor, if any.
--- @return string[]
function M._get_urls()
local urls = {} ---@type string[]
local bufnr = vim.api.nvim_get_current_buf()
local cursor = vim.api.nvim_win_get_cursor(0)
local row = cursor[1] - 1
local col = cursor[2]
urls = vim.list_extend(urls, get_lsp_urls(bufnr))
local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, -1, { row, col }, { row, col }, {
details = true,
type = 'highlight',
overlap = true,
})
for _, v in ipairs(extmarks) do
local details = v[4]
if details and details.url then
urls[#urls + 1] = details.url
end
end
local highlighter = vim.treesitter.highlighter.active[bufnr]
if highlighter then
local range = { row, col, row, col }
local ltree = highlighter.tree:language_for_range(range)
local lang = ltree:lang()
local query = vim.treesitter.query.get(lang, 'highlights')
if query then
local tree = assert(ltree:tree_for_range(range))
for _, match, metadata in query:iter_matches(tree:root(), bufnr, row, row + 1) do
for id, nodes in pairs(match) do
for _, node in ipairs(nodes) do
if vim.treesitter.node_contains(node, range) then
local url = metadata[id] and metadata[id].url
if url and match[url] then
for _, n in
ipairs(match[url] --[[@as TSNode[] ]])
do
urls[#urls + 1] =
vim.treesitter.get_node_text(n, bufnr, { metadata = metadata[url] })
end
end
end
end
end
end
end
end
if #urls == 0 then
-- If all else fails, use the filename under the cursor
table.insert(
urls,
vim._with({ go = { isfname = vim.o.isfname .. ',@-@' } }, function()
return vim.fn.expand('<cfile>')
end)
)
end
return urls
end
do
--- Cache of active progress messages, keyed by msg_id
--- TODO(justinmk): visibility of "stale" (never-finished) Progress. https://github.com/neovim/neovim/pull/35428#discussion_r2942696157
---@type table<integer, vim.event.progress.data>
local progress = {}
-- store progress events
local progress_group, progress_autocmd = nil, nil
--- Initialize Progress handlers.
local function progress_init()
progress_group = vim.api.nvim_create_augroup('nvim.ui.progress_status', { clear = true })
progress_autocmd = vim.api.nvim_create_autocmd('Progress', {
group = progress_group,
desc = 'Tracks progress messages for vim.ui.progress_status()',
---@param ev {data: vim.event.progress.data}
callback = function(ev)
if not ev.data or not ev.data.id then
return
end
ev.data.percent = ev.data.percent or 0
progress[ev.data.id] = ev.data
-- Clear finished items
if
ev.data.status == 'success'
or ev.data.percent == 100
or ev.data.status == 'failed'
or ev.data.status == 'cancel'
then
progress[ev.data.id] = nil
end
end,
})
end
--- Gets a status description summarizing currently running progress messages.
--- - If none: returns empty string
--- - If N item running: "AVG%(N)"
---@param running vim.event.progress.data[]
---@return string
local function progress_status_fmt(running)
local count = #running
if count == 0 then
return '' -- nothing to show
else
local sum = 0 ---@type integer
for _, progress_item in ipairs(running) do
sum = sum + (progress_item.percent or 0)
end
local avg = math.floor(sum / count)
return string.format('%d%%%%(%d) ', avg, count)
end
end
--- Gets a status description summarizing currently running progress messages.
--- Convenient for inclusion in 'statusline'.
---
---@return string # Progress status
function M.progress_status()
if progress_autocmd == nil then
progress_init()
end
local running = vim.tbl_values(progress)
return progress_status_fmt(running) or ''
end
end
return M