aerial.nvim/lua/aerial/window.lua
2024-06-01 19:26:10 -07:00

556 lines
17 KiB
Lua

local backends = require("aerial.backends")
local config = require("aerial.config")
local data = require("aerial.data")
local keymap_util = require("aerial.keymap_util")
local layout = require("aerial.layout")
local loading = require("aerial.loading")
local render = require("aerial.render")
local util = require("aerial.util")
local M = {}
local function create_aerial_buffer(bufnr)
if bufnr == 0 then
bufnr = vim.api.nvim_get_current_buf()
end
local aer_bufnr = vim.api.nvim_create_buf(false, true)
keymap_util.set_keymaps("", "aerial.actions", config.keymaps, aer_bufnr)
vim.api.nvim_buf_set_var(bufnr, "aerial_buffer", aer_bufnr)
-- Set buffer options
vim.api.nvim_buf_set_var(aer_bufnr, "source_buffer", bufnr)
vim.bo[aer_bufnr].buftype = "nofile"
vim.bo[aer_bufnr].bufhidden = "wipe"
vim.bo[aer_bufnr].buflisted = false
vim.bo[aer_bufnr].swapfile = false
vim.bo[aer_bufnr].modifiable = false
if config.highlight_on_hover or config.autojump then
vim.api.nvim_create_autocmd("CursorMoved", {
desc = "Aerial update highlights in the source buffer",
buffer = aer_bufnr,
callback = function()
if config.highlight_on_hover then
render.update_highlights(bufnr)
end
if config.autojump and vim.b[aer_bufnr].rendered then
require("aerial.navigation").select({ jump = false, quiet = true })
end
end,
})
end
if config.highlight_on_hover then
vim.api.nvim_create_autocmd("BufLeave", {
desc = "Aerial clear highlights in the source buffer",
buffer = aer_bufnr,
callback = function(params)
render.clear_highlights(bufnr)
end,
})
end
vim.api.nvim_create_autocmd("BufWinEnter", {
desc = "Aerial render symbols after buffer loads in window",
buffer = aer_bufnr,
once = true,
callback = function(params)
-- Defer it so we have time to set window options and variables on the float first
vim.defer_fn(function()
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
render.update_aerial_buffer(aer_bufnr)
M.update_all_positions(bufnr, 0)
M.center_symbol_in_view(bufnr)
end, 1)
end,
})
if not data.has_symbols(bufnr) then
loading.set_loading(aer_bufnr, true)
-- Give the backends 50ms to figure out if any of them are supported. If none are supported
-- after that timeout, assume that they won't be and reset the loading status.
vim.defer_fn(function()
local backend = backends.get(bufnr)
if not backend and loading.is_loading(aer_bufnr) then
loading.set_loading(aer_bufnr, false)
render.update_aerial_buffer(aer_bufnr)
end
end, 50)
end
return aer_bufnr
end
local default_win_opts = {
list = false,
winfixwidth = true,
number = false,
signcolumn = "no",
foldcolumn = "0",
relativenumber = false,
wrap = false,
spell = false,
}
---@param src_winid integer
---@param aer_winid integer
local function setup_aerial_win(src_winid, aer_winid, aer_bufnr)
vim.api.nvim_win_set_buf(aer_winid, aer_bufnr)
for k, v in pairs(default_win_opts) do
vim.api.nvim_set_option_value(k, v, { scope = "local", win = aer_winid })
end
for k, v in pairs(config.layout.win_opts) do
vim.api.nvim_set_option_value(k, v, { scope = "local", win = aer_winid })
end
vim.api.nvim_win_set_var(aer_winid, "is_aerial_win", true)
vim.api.nvim_win_set_var(aer_winid, "source_win", src_winid)
vim.api.nvim_win_set_var(src_winid, "aerial_win", aer_winid)
-- Set the filetype only after we enter the buffer so that ftplugins behave properly
vim.bo[aer_bufnr].filetype = "aerial"
local width = vim.b[aer_bufnr].aerial_width
if width and (not vim.w[aer_winid].aerial_set_width or config.layout.resize_to_content) then
vim.api.nvim_win_set_width(aer_winid, width)
vim.w[aer_winid].aerial_set_width = true
end
if config.layout.preserve_equality then
vim.cmd.wincmd({ args = { "=" } })
end
end
---@param bufnr nil|integer
---@param aer_bufnr nil|integer
---@param direction "left"|"right"|"float"
---@param existing_win nil|integer
---@return integer
local function create_aerial_window(bufnr, aer_bufnr, direction, existing_win)
if direction ~= "left" and direction ~= "right" and direction ~= "float" then
error("Expected direction to be 'left', 'right', or 'float'")
end
if not aer_bufnr then
aer_bufnr = create_aerial_buffer(bufnr)
end
local my_winid = vim.api.nvim_get_current_win()
local aer_winid
if not existing_win then
if direction == "float" then
local rel = config.float.relative
local width = layout.calculate_width(rel, nil, config.layout)
local height = layout.calculate_height(rel, nil, config.float)
local row = layout.calculate_row(rel, height)
local col = layout.calculate_col(rel, width)
local win_config = {
relative = rel,
row = row,
col = col,
width = width,
height = height,
zindex = 125,
style = "minimal",
border = config.float.border,
}
if rel == "win" then
win_config.win = vim.api.nvim_get_current_win()
end
local new_config = config.float.override(win_config, my_winid) or win_config
aer_winid = vim.api.nvim_open_win(aer_bufnr, false, new_config)
-- We store this as a window variable because relative=cursor gets
-- turned into relative=win when checking nvim_win_get_config()
vim.api.nvim_win_set_var(aer_winid, "relative", new_config.relative)
local win_enter_au
win_enter_au = vim.api.nvim_create_autocmd("WinEnter", {
desc = "After entering aerial win, add hook to close it when leaving",
callback = function()
if vim.api.nvim_get_current_win() == aer_winid then
vim.api.nvim_create_autocmd("WinLeave", {
desc = "Close aerial floating win when leaving",
callback = function()
pcall(vim.api.nvim_win_close, aer_winid, true)
end,
once = true,
nested = true,
})
vim.api.nvim_del_autocmd(win_enter_au)
elseif not vim.api.nvim_win_is_valid(aer_winid) then
vim.api.nvim_del_autocmd(win_enter_au)
end
end,
})
else
local modifier
if config.layout.placement == "edge" then
modifier = direction == "left" and "topleft" or "botright"
else
modifier = direction == "left" and "leftabove" or "rightbelow"
end
vim.cmd(string.format("noau vertical %s 1split", modifier))
aer_winid = vim.api.nvim_get_current_win()
end
else
aer_winid = existing_win
end
util.go_win_no_au(aer_winid)
setup_aerial_win(my_winid, aer_winid, aer_bufnr)
util.go_win_no_au(my_winid)
return aer_winid
end
---@param src_bufnr integer source buffer
---@param src_winid integer window containing source buffer
---@param aer_winid integer aerial window
M.open_aerial_in_win = function(src_bufnr, src_winid, aer_winid)
if src_winid == 0 then
src_winid = vim.api.nvim_get_current_win()
end
if aer_winid == 0 then
aer_winid = vim.api.nvim_get_current_win()
end
local aer_bufnr = util.get_aerial_buffer(src_bufnr)
-- If aerial is already open in the window, early return
if aer_bufnr == vim.api.nvim_win_get_buf(aer_winid) then
-- Always update the source/aerial win pointers because attach_mode = "global" requires that
-- they be up to date. We may be calling open_aerial_in_win for same buffer but in a new win.
vim.api.nvim_win_set_var(aer_winid, "source_win", src_winid)
vim.api.nvim_win_set_var(src_winid, "aerial_win", aer_winid)
return
end
if not aer_bufnr then
aer_bufnr = create_aerial_buffer(src_bufnr)
end
local my_winid = vim.api.nvim_get_current_win()
util.go_win_no_au(aer_winid)
setup_aerial_win(src_winid, aer_winid, aer_bufnr)
util.go_win_no_au(my_winid)
local backend = backends.get(src_bufnr)
if backend and not data.has_symbols(src_bufnr) then
backend.fetch_symbols(src_bufnr)
end
end
---@param opts? {bufnr?: integer, winid?: integer}
---@return boolean
M.is_open = function(opts)
if not opts then
opts = { winid = 0 }
end
if opts.winid then
return util.get_aerial_win(opts.winid) ~= nil
else
local aer_bufnr = util.get_aerial_buffer(opts.bufnr)
if aer_bufnr then
return util.buf_first_win_in_tabpage(aer_bufnr) ~= nil
end
return false
end
end
M.close = function()
if util.is_aerial_buffer() then
local source_win = util.get_source_win(0)
vim.api.nvim_win_close(0, false)
if source_win then
vim.api.nvim_set_current_win(source_win)
end
else
local aer_win = util.get_aerial_win()
if aer_win then
vim.api.nvim_win_close(aer_win, false)
else
-- No aerial buffer for this buffer.
local backend = backends.get(0)
-- If this buffer has no supported symbols backend, or no symbols, or is ignored,
-- look for other aerial windows and close the first
if backend == nil or not data.has_symbols(0) or util.is_ignored_win() then
for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.api.nvim_win_is_valid(winid) then
local winbuf = vim.api.nvim_win_get_buf(winid)
if util.is_aerial_buffer(winbuf) then
vim.api.nvim_win_close(winid, false)
break
end
end
end
end
end
end
if config.layout.preserve_equality then
vim.cmd.wincmd({ args = { "=" } })
end
end
M.close_all = function()
for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if
vim.api.nvim_win_is_valid(winid) and util.is_aerial_buffer(vim.api.nvim_win_get_buf(winid))
then
vim.api.nvim_win_close(winid, false)
end
end
end
M.close_all_but_current = function()
local _, aer_winid = util.get_winids(0)
for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.api.nvim_win_is_valid(winid) then
local bufnr = vim.api.nvim_win_get_buf(winid)
if winid ~= aer_winid and util.is_aerial_buffer(bufnr) then
vim.api.nvim_win_close(winid, false)
end
end
end
end
---@param bufnr? integer
---@return boolean
M.maybe_open_automatic = function(bufnr)
bufnr = bufnr or 0
if config.open_automatic(bufnr) and backends.get(bufnr) then
M.open(false)
return true
else
return false
end
end
---@param focus? boolean
---@param direction? "left"|"right"|"float"
M.open = function(focus, direction)
if util.is_aerial_buffer(0) then
return
end
local bufnr, aer_bufnr = util.get_buffers()
local aerial_win = util.get_aerial_win()
if aerial_win and aer_bufnr == vim.api.nvim_win_get_buf(aerial_win) then
if focus then
vim.api.nvim_set_current_win(aerial_win)
end
return
end
direction = direction or util.detect_split_direction()
local aer_winid = create_aerial_window(bufnr, aer_bufnr, direction, aerial_win)
local backend = backends.get(0)
if backend and not data.has_symbols(bufnr) then
backend.fetch_symbols(bufnr)
end
if focus then
vim.api.nvim_set_current_win(aer_winid)
end
end
M.open_all = function()
if config.attach_mode == "global" then
return M.open()
end
for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if
vim.api.nvim_win_is_valid(winid)
and not util.is_ignored_win(winid)
and not util.is_floating_win(winid)
then
vim.api.nvim_win_call(winid, function()
M.open()
end)
end
end
end
M.focus = function()
local aerial_win = util.get_aerial_win()
if aerial_win then
vim.api.nvim_set_current_win(aerial_win)
end
end
M.toggle = function(focus, direction)
if util.is_aerial_buffer() or M.is_open() then
M.close()
return false
else
M.open(focus, direction)
return true
end
end
---@param bufnr? integer
---@param winid? integer
---@return aerial.CursorPosition
M.get_position_in_win = function(bufnr, winid)
local cursor = vim.api.nvim_win_get_cursor(winid or 0)
local lnum = cursor[1]
local col = cursor[2]
local bufdata = data.get_or_create(bufnr)
return M.get_symbol_position(bufdata, lnum, col)
end
---Returns -1 if item is before position, 0 if equal, 1 if after
---@param range aerial.Range
---@param lnum integer
---@param col integer
---@return integer
---@return boolean True when position is fully inside the range
local function compare(range, lnum, col)
if range.lnum > lnum then
return 1, false
elseif range.lnum == lnum then
if range.col > col then
return 1, false
elseif range.col == col then
return 0, true
else
return -1, range.end_lnum > lnum or (range.end_lnum == lnum and range.end_col >= col)
end
else
return -1, range.end_lnum > lnum or (range.end_lnum == lnum and range.end_col >= col)
end
end
---@class aerial.CursorPosition
---@field lnum integer
---@field closest_symbol aerial.Symbol
---@field exact_symbol aerial.Symbol|nil
---@field relative "exact"|"below"|"above"
---@param bufdata aerial.BufData
---@param lnum integer
---@param col integer
---@param include_hidden nil|boolean
---@return aerial.CursorPosition
M.get_symbol_position = function(bufdata, lnum, col, include_hidden)
local selected = 0
local relative = "above"
local prev = nil
local exact_symbol
local symbol
for _, item in bufdata:iter({ skip_hidden = not include_hidden }) do
local cmp, inside = compare(item, lnum, col)
if inside then
exact_symbol = item
if item.selection_range then
cmp = compare(item.selection_range, lnum, col)
end
end
if cmp < 0 then
relative = "below"
elseif cmp == 0 then
selected = selected + 1
relative = "exact"
symbol = item
break
else
symbol = prev or item
break
end
prev = item
selected = selected + 1
end
-- Check if we're on the last symbol
if symbol == nil then
symbol = prev
end
return {
lnum = math.max(1, selected),
closest_symbol = symbol,
exact_symbol = exact_symbol,
relative = relative,
}
end
-- Updates all cursor positions for a given source buffer
M.update_all_positions = function(bufnr, last_focused_win)
local source_buffer = util.get_buffers(bufnr)
local all_source_wins = util.get_non_ignored_fixed_wins(source_buffer)
M.update_position(all_source_wins, last_focused_win)
end
-- Update the cursor position for one or more windows
-- winids can be nil, a winid, or a list of winids
---@param winids nil|integer|integer[]
---@param last_focused_win nil|integer
M.update_position = function(winids, last_focused_win)
if winids == nil or winids == 0 then
winids = { vim.api.nvim_get_current_win() }
elseif type(winids) ~= "table" then
winids = { winids }
end
if #winids == 0 then
return
end
if last_focused_win == 0 then
last_focused_win = vim.api.nvim_get_current_win()
end
-- If the last_focused_win is actually an aerial window, instead use the source window for that
-- aerial win (if any)
if last_focused_win and util.is_aerial_win(last_focused_win) then
last_focused_win = util.get_source_win(last_focused_win)
end
local win_bufnr = vim.api.nvim_win_get_buf(winids[1])
local bufnr = util.get_buffers(win_bufnr)
if not bufnr or not data.has_symbols(bufnr) then
return
end
if util.is_aerial_buffer(win_bufnr) then
winids = util.get_non_ignored_fixed_wins(bufnr)
end
local bufdata = data.get_or_create(bufnr)
for _, target_win in ipairs(winids) do
local pos = M.get_position_in_win(bufnr, target_win)
if pos ~= nil then
bufdata.positions[target_win] = pos
if last_focused_win == target_win then
bufdata.last_win = target_win
end
end
end
render.update_highlights(bufnr)
if last_focused_win then
local aer_winid = util.get_aerial_win(last_focused_win)
if aer_winid then
local last_position = bufdata.positions[bufdata.last_win]
local aer_bufnr = vim.api.nvim_win_get_buf(aer_winid)
local num_lines = vim.api.nvim_buf_line_count(aer_bufnr)
-- When aerial window is global, the items can change and cursor will move
-- before the symbols are published, which causes the line number to be
-- invalid.
if last_position and num_lines >= last_position.lnum then
vim.api.nvim_win_set_cursor(aer_winid, { last_position.lnum, 0 })
end
end
end
end
---@param buffer nil|integer
M.center_symbol_in_view = function(buffer)
local bufnr, aer_bufnr = util.get_buffers(buffer)
if not bufnr or not data.has_symbols(bufnr) or not aer_bufnr then
return
end
local bufdata = data.get_or_create(bufnr)
if not bufdata.last_win then
return
end
if vim.api.nvim_buf_is_valid(aer_bufnr) and vim.api.nvim_win_is_valid(bufdata.last_win) then
local last_position = bufdata.positions[bufdata.last_win]
if last_position then
local lnum = last_position.lnum
local height = vim.api.nvim_win_get_height(bufdata.last_win)
local max_topline = vim.api.nvim_buf_line_count(aer_bufnr) - height
local topline = math.max(1, math.min(max_topline, lnum - math.floor(height / 2)))
local aerial_win = util.buf_first_win_in_tabpage(aer_bufnr)
if aerial_win then
vim.api.nvim_win_call(aerial_win, function()
vim.fn.winrestview({ lnum = lnum, topline = topline })
end)
end
end
end
end
return M