mirror of
https://github.com/SpaceVim/SpaceVim.git
synced 2025-02-04 23:40:06 +08:00
485 lines
16 KiB
Lua
Vendored
485 lines
16 KiB
Lua
Vendored
--- popup.lua
|
|
---
|
|
--- Wrapper to make the popup api from vim in neovim.
|
|
--- Hope to get this part merged in at some point in the future.
|
|
---
|
|
--- Please make sure to update "POPUP.md" with any changes and/or notes.
|
|
|
|
local Border = require "plenary.window.border"
|
|
local Window = require "plenary.window"
|
|
local utils = require "plenary.popup.utils"
|
|
|
|
local if_nil = vim.F.if_nil
|
|
|
|
local popup = {}
|
|
|
|
popup._pos_map = {
|
|
topleft = "NW",
|
|
topright = "NE",
|
|
botleft = "SW",
|
|
botright = "SE",
|
|
}
|
|
|
|
-- Keep track of hidden popups, so we can load them with popup.show()
|
|
popup._hidden = {}
|
|
|
|
-- Keep track of popup borders, so we don't have to pass them between functions
|
|
popup._borders = {}
|
|
|
|
local function dict_default(options, key, default)
|
|
if options[key] == nil then
|
|
return default[key]
|
|
else
|
|
return options[key]
|
|
end
|
|
end
|
|
|
|
-- Callbacks to be called later by popup.execute_callback
|
|
popup._callbacks = {}
|
|
|
|
-- Convert the positional {vim_options} to compatible neovim options and add them to {win_opts}
|
|
-- If an option is not given in {vim_options}, fall back to {default_opts}
|
|
local function add_position_config(win_opts, vim_options, default_opts)
|
|
default_opts = default_opts or {}
|
|
|
|
local cursor_relative_pos = function(pos_str, dim)
|
|
assert(string.find(pos_str, "^cursor"), "Invalid value for " .. dim)
|
|
win_opts.relative = "cursor"
|
|
local line = 0
|
|
if (pos_str):match "cursor%+(%d+)" then
|
|
line = line + tonumber((pos_str):match "cursor%+(%d+)")
|
|
elseif (pos_str):match "cursor%-(%d+)" then
|
|
line = line - tonumber((pos_str):match "cursor%-(%d+)")
|
|
end
|
|
return line
|
|
end
|
|
|
|
-- Feels like maxheight, minheight, maxwidth, minwidth will all be related
|
|
--
|
|
-- maxheight Maximum height of the contents, excluding border and padding.
|
|
-- minheight Minimum height of the contents, excluding border and padding.
|
|
-- maxwidth Maximum width of the contents, excluding border, padding and scrollbar.
|
|
-- minwidth Minimum width of the contents, excluding border, padding and scrollbar.
|
|
local width = if_nil(vim_options.width, default_opts.width)
|
|
local height = if_nil(vim_options.height, default_opts.height)
|
|
win_opts.width = utils.bounded(width, vim_options.minwidth, vim_options.maxwidth)
|
|
win_opts.height = utils.bounded(height, vim_options.minheight, vim_options.maxheight)
|
|
|
|
if vim_options.line and vim_options.line ~= 0 then
|
|
if type(vim_options.line) == "string" then
|
|
win_opts.row = cursor_relative_pos(vim_options.line, "row")
|
|
else
|
|
win_opts.row = vim_options.line - 1
|
|
end
|
|
else
|
|
win_opts.row = math.floor((vim.o.lines - win_opts.height) / 2)
|
|
end
|
|
|
|
if vim_options.col and vim_options.col ~= 0 then
|
|
if type(vim_options.col) == "string" then
|
|
win_opts.col = cursor_relative_pos(vim_options.col, "col")
|
|
else
|
|
win_opts.col = vim_options.col - 1
|
|
end
|
|
else
|
|
win_opts.col = math.floor((vim.o.columns - win_opts.width) / 2)
|
|
end
|
|
|
|
-- pos
|
|
--
|
|
-- Using "topleft", "topright", "botleft", "botright" defines what corner of the popup "line"
|
|
-- and "col" are used for. When not set "topleft" behaviour is used.
|
|
-- Alternatively "center" can be used to position the popup in the center of the Neovim window,
|
|
-- in which case "line" and "col" are ignored.
|
|
if vim_options.pos then
|
|
if vim_options.pos == "center" then
|
|
vim_options.line = 0
|
|
vim_options.col = 0
|
|
win_opts.anchor = "NW"
|
|
else
|
|
win_opts.anchor = popup._pos_map[vim_options.pos]
|
|
end
|
|
else
|
|
win_opts.anchor = "NW" -- This is the default, but makes `posinvert` easier to implement
|
|
end
|
|
|
|
-- , fixed When FALSE (the default), and:
|
|
-- , - "pos" is "botleft" or "topleft", and
|
|
-- , - "wrap" is off, and
|
|
-- , - the popup would be truncated at the right edge of
|
|
-- , the screen, then
|
|
-- , the popup is moved to the left so as to fit the
|
|
-- , contents on the screen. Set to TRUE to disable this.
|
|
end
|
|
|
|
function popup.create(what, vim_options)
|
|
vim_options = vim.deepcopy(vim_options)
|
|
|
|
local bufnr
|
|
if type(what) == "number" then
|
|
bufnr = what
|
|
else
|
|
bufnr = vim.api.nvim_create_buf(false, true)
|
|
assert(bufnr, "Failed to create buffer")
|
|
|
|
vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe")
|
|
|
|
-- TODO: Handle list of lines
|
|
if type(what) == "string" then
|
|
what = { what }
|
|
else
|
|
assert(type(what) == "table", '"what" must be a table')
|
|
end
|
|
|
|
-- padding List with numbers, defining the padding
|
|
-- above/right/below/left of the popup (similar to CSS).
|
|
-- An empty list uses a padding of 1 all around. The
|
|
-- padding goes around the text, inside any border.
|
|
-- Padding uses the 'wincolor' highlight.
|
|
-- Example: [1, 2, 1, 3] has 1 line of padding above, 2
|
|
-- columns on the right, 1 line below and 3 columns on
|
|
-- the left.
|
|
if vim_options.padding then
|
|
local pad_top, pad_right, pad_below, pad_left
|
|
if vim.tbl_isempty(vim_options.padding) then
|
|
pad_top = 1
|
|
pad_right = 1
|
|
pad_below = 1
|
|
pad_left = 1
|
|
else
|
|
local padding = vim_options.padding
|
|
pad_top = padding[1] or 0
|
|
pad_right = padding[2] or 0
|
|
pad_below = padding[3] or 0
|
|
pad_left = padding[4] or 0
|
|
end
|
|
|
|
local left_padding = string.rep(" ", pad_left)
|
|
local right_padding = string.rep(" ", pad_right)
|
|
for index = 1, #what do
|
|
what[index] = string.format("%s%s%s", left_padding, what[index], right_padding)
|
|
end
|
|
|
|
for _ = 1, pad_top do
|
|
table.insert(what, 1, "")
|
|
end
|
|
|
|
for _ = 1, pad_below do
|
|
table.insert(what, "")
|
|
end
|
|
end
|
|
|
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, what)
|
|
end
|
|
|
|
local option_defaults = {
|
|
posinvert = true,
|
|
zindex = 50,
|
|
}
|
|
|
|
vim_options.width = if_nil(vim_options.width, 1)
|
|
if type(what) == "number" then
|
|
vim_options.height = vim.api.nvim_buf_line_count(what)
|
|
else
|
|
for _, v in ipairs(what) do
|
|
vim_options.width = math.max(vim_options.width, #v)
|
|
end
|
|
vim_options.height = #what
|
|
end
|
|
|
|
local win_opts = {}
|
|
win_opts.relative = "editor"
|
|
win_opts.style = "minimal"
|
|
|
|
-- Add positional and sizing config to win_opts
|
|
add_position_config(win_opts, vim_options, { width = 1, height = 1 })
|
|
|
|
-- posinvert, When FALSE the value of "pos" is always used. When
|
|
-- , TRUE (the default) and the popup does not fit
|
|
-- , vertically and there is more space on the other side
|
|
-- , then the popup is placed on the other side of the
|
|
-- , position indicated by "line".
|
|
if dict_default(vim_options, "posinvert", option_defaults) then
|
|
if win_opts.anchor == "NW" or win_opts.anchor == "NE" then
|
|
if win_opts.row + win_opts.height > vim.o.lines and win_opts.row * 2 > vim.o.lines then
|
|
-- Don't know why, but this is how vim adjusts it
|
|
win_opts.row = win_opts.row - win_opts.height - 2
|
|
end
|
|
elseif win_opts.anchor == "SW" or win_opts.anchor == "SE" then
|
|
if win_opts.row - win_opts.height < 0 and win_opts.row * 2 < vim.o.lines then
|
|
-- Don't know why, but this is how vim adjusts it
|
|
win_opts.row = win_opts.row + win_opts.height + 2
|
|
end
|
|
end
|
|
end
|
|
|
|
-- textprop, When present the popup is positioned next to a text
|
|
-- , property with this name and will move when the text
|
|
-- , property moves. Use an empty string to remove. See
|
|
-- , |popup-textprop-pos|.
|
|
-- related:
|
|
-- textpropwin
|
|
-- textpropid
|
|
|
|
-- zindex, Priority for the popup, default 50. Minimum value is
|
|
-- , 1, maximum value is 32000.
|
|
local zindex = dict_default(vim_options, "zindex", option_defaults)
|
|
win_opts.zindex = utils.bounded(zindex, 1, 32000)
|
|
|
|
-- noautocmd, undocumented vim default per https://github.com/vim/vim/issues/5737
|
|
win_opts.noautocmd = if_nil(vim_options.noautocmd, true)
|
|
|
|
-- focusable,
|
|
-- vim popups are not focusable windows
|
|
win_opts.focusable = if_nil(vim_options.focusable, false)
|
|
|
|
local win_id
|
|
if vim_options.hidden then
|
|
assert(false, "I have not implemented this yet and don't know how")
|
|
else
|
|
win_id = vim.api.nvim_open_win(bufnr, false, win_opts)
|
|
end
|
|
|
|
-- Moved, handled after since we need the window ID
|
|
if vim_options.moved then
|
|
if vim_options.moved == "any" then
|
|
vim.lsp.util.close_preview_autocmd({ "CursorMoved", "CursorMovedI" }, win_id)
|
|
elseif vim_options.moved == "word" then
|
|
-- TODO: Handle word, WORD, expr, and the range functions... which seem hard?
|
|
end
|
|
else
|
|
local silent = false
|
|
vim.cmd(
|
|
string.format(
|
|
"autocmd BufDelete %s <buffer=%s> ++once ++nested :lua require('plenary.window').try_close(%s, true)",
|
|
(silent and "<silent>") or "",
|
|
bufnr,
|
|
win_id
|
|
)
|
|
)
|
|
end
|
|
|
|
if vim_options.time then
|
|
local timer = vim.loop.new_timer()
|
|
timer:start(
|
|
vim_options.time,
|
|
0,
|
|
vim.schedule_wrap(function()
|
|
Window.try_close(win_id, false)
|
|
end)
|
|
)
|
|
end
|
|
|
|
-- Buffer Options
|
|
if vim_options.cursorline then
|
|
vim.api.nvim_win_set_option(win_id, "cursorline", true)
|
|
end
|
|
|
|
if vim_options.wrap ~= nil then
|
|
-- set_option wrap should/will trigger autocmd, see https://github.com/neovim/neovim/pull/13247
|
|
if vim_options.noautocmd then
|
|
vim.cmd(string.format("noautocmd lua vim.api.nvim_set_option(%s, wrap, %s)", win_id, vim_options.wrap))
|
|
else
|
|
vim.api.nvim_win_set_option(win_id, "wrap", vim_options.wrap)
|
|
end
|
|
end
|
|
|
|
-- ===== Not Implemented Options =====
|
|
-- flip: not implemented at the time of writing
|
|
-- Mouse:
|
|
-- mousemoved: no idea how to do the things with the mouse, so it's an exercise for the reader.
|
|
-- drag: mouses are hard
|
|
-- resize: mouses are hard
|
|
-- close: mouses are hard
|
|
--
|
|
-- scrollbar
|
|
-- scrollbarhighlight
|
|
-- thumbhighlight
|
|
|
|
-- tabpage: seems useless
|
|
|
|
-- Create border
|
|
|
|
local should_show_border = nil
|
|
local border_options = {}
|
|
|
|
-- border List with numbers, defining the border thickness
|
|
-- above/right/below/left of the popup (similar to CSS).
|
|
-- Only values of zero and non-zero are recognized.
|
|
-- An empty list uses a border all around.
|
|
if vim_options.border then
|
|
should_show_border = true
|
|
|
|
if type(vim_options.border) == "boolean" or vim.tbl_isempty(vim_options.border) then
|
|
border_options.border_thickness = Border._default_thickness
|
|
elseif #vim_options.border == 4 then
|
|
border_options.border_thickness = {
|
|
top = utils.bounded(vim_options.border[1], 0, 1),
|
|
right = utils.bounded(vim_options.border[2], 0, 1),
|
|
bot = utils.bounded(vim_options.border[3], 0, 1),
|
|
left = utils.bounded(vim_options.border[4], 0, 1),
|
|
}
|
|
else
|
|
error(string.format("Invalid configuration for border: %s", vim.inspect(vim_options.border)))
|
|
end
|
|
elseif vim_options.border == false then
|
|
should_show_border = false
|
|
end
|
|
|
|
if (should_show_border == nil or should_show_border) and vim_options.borderchars then
|
|
should_show_border = true
|
|
|
|
-- borderchars List with characters, defining the character to use
|
|
-- for the top/right/bottom/left border. Optionally
|
|
-- followed by the character to use for the
|
|
-- topleft/topright/botright/botleft corner.
|
|
-- Example: ['-', '|', '-', '|', '┌', '┐', '┘', '└']
|
|
-- When the list has one character it is used for all.
|
|
-- When the list has two characters the first is used for
|
|
-- the border lines, the second for the corners.
|
|
-- By default a double line is used all around when
|
|
-- 'encoding' is "utf-8" and 'ambiwidth' is "single",
|
|
-- otherwise ASCII characters are used.
|
|
|
|
local b_top, b_right, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft
|
|
if vim_options.borderchars == nil then
|
|
b_top, b_right, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft =
|
|
"═", "║", "═", "║", "╔", "╗", "╝", "╚"
|
|
elseif #vim_options.borderchars == 1 then
|
|
local b_char = vim_options.borderchars[1]
|
|
b_top, b_right, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft =
|
|
b_char, b_char, b_char, b_char, b_char, b_char, b_char, b_char
|
|
elseif #vim_options.borderchars == 2 then
|
|
local b_char = vim_options.borderchars[1]
|
|
local c_char = vim_options.borderchars[2]
|
|
b_top, b_right, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft =
|
|
b_char, b_char, b_char, b_char, c_char, c_char, c_char, c_char
|
|
elseif #vim_options.borderchars == 8 then
|
|
b_top, b_right, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft = unpack(vim_options.borderchars)
|
|
else
|
|
error(string.format 'Not enough arguments for "borderchars"')
|
|
end
|
|
|
|
border_options.top = b_top
|
|
border_options.bot = b_bot
|
|
border_options.right = b_right
|
|
border_options.left = b_left
|
|
border_options.topleft = b_topleft
|
|
border_options.topright = b_topright
|
|
border_options.botright = b_botright
|
|
border_options.botleft = b_botleft
|
|
end
|
|
|
|
-- title
|
|
if vim_options.title then
|
|
-- TODO: Check out how title works with weird border combos.
|
|
border_options.title = vim_options.title
|
|
end
|
|
|
|
local border = nil
|
|
if should_show_border then
|
|
border_options.focusable = vim_options.border_focusable
|
|
border_options.highlight = vim_options.borderhighlight and string.format("Normal:%s", vim_options.borderhighlight)
|
|
border_options.titlehighlight = vim_options.titlehighlight
|
|
border = Border:new(bufnr, win_id, win_opts, border_options)
|
|
popup._borders[win_id] = border
|
|
end
|
|
|
|
if vim_options.highlight then
|
|
vim.api.nvim_win_set_option(
|
|
win_id,
|
|
"winhl",
|
|
string.format("Normal:%s,EndOfBuffer:%s", vim_options.highlight, vim_options.highlight)
|
|
)
|
|
end
|
|
|
|
-- enter
|
|
local should_enter = vim_options.enter
|
|
if should_enter == nil then
|
|
should_enter = true
|
|
end
|
|
|
|
if should_enter then
|
|
-- set focus after border creation so that it's properly placed (especially
|
|
-- in relative cursor layout)
|
|
if vim_options.noautocmd then
|
|
vim.cmd("noautocmd lua vim.api.nvim_set_current_win(" .. win_id .. ")")
|
|
else
|
|
vim.api.nvim_set_current_win(win_id)
|
|
end
|
|
end
|
|
|
|
-- callback
|
|
if vim_options.callback then
|
|
popup._callbacks[bufnr] = function()
|
|
-- (jbyuki): Giving win_id is pointless here because it's closed right afterwards
|
|
-- but it might make more sense once hidden is implemented
|
|
local row, _ = unpack(vim.api.nvim_win_get_cursor(win_id))
|
|
vim_options.callback(win_id, what[row])
|
|
vim.api.nvim_win_close(win_id, true)
|
|
end
|
|
vim.api.nvim_buf_set_keymap(
|
|
bufnr,
|
|
"n",
|
|
"<CR>",
|
|
'<cmd>lua require"popup".execute_callback(' .. bufnr .. ")<CR>",
|
|
{ noremap = true }
|
|
)
|
|
end
|
|
|
|
-- TODO: Perhaps there's a way to return an object that looks like a window id,
|
|
-- but actually has some extra metadata about it.
|
|
--
|
|
-- This would make `hidden` a lot easier to manage
|
|
return win_id, {
|
|
win_id = win_id,
|
|
border = border,
|
|
}
|
|
end
|
|
|
|
-- Move popup with window id {win_id} to the position specified with {vim_options}.
|
|
-- {vim_options} may contain the following items that determine the popup position/size:
|
|
-- - line
|
|
-- - col
|
|
-- - height
|
|
-- - width
|
|
-- - maxheight/minheight
|
|
-- - maxwidth/minwidth
|
|
-- - pos
|
|
-- Unimplemented vim options here include: fixed
|
|
function popup.move(win_id, vim_options)
|
|
-- Create win_options
|
|
local win_opts = {}
|
|
win_opts.relative = "editor"
|
|
|
|
local current_pos = vim.api.nvim_win_get_position(win_id)
|
|
local default_opts = {
|
|
width = vim.api.nvim_win_get_width(win_id),
|
|
height = vim.api.nvim_win_get_height(win_id),
|
|
row = current_pos[1],
|
|
col = current_pos[2],
|
|
}
|
|
|
|
-- Add positional and sizing config to win_opts
|
|
add_position_config(win_opts, vim_options, default_opts)
|
|
|
|
-- Update content window
|
|
vim.api.nvim_win_set_config(win_id, win_opts)
|
|
|
|
-- Update border window (if present)
|
|
local border = popup._borders[win_id]
|
|
if border ~= nil then
|
|
border:move(win_opts, border._border_win_options)
|
|
end
|
|
end
|
|
|
|
function popup.execute_callback(bufnr)
|
|
if popup._callbacks[bufnr] then
|
|
local wrapper = popup._callbacks[bufnr]
|
|
wrapper()
|
|
popup._callbacks[bufnr] = nil
|
|
end
|
|
end
|
|
|
|
return popup
|