snacks.nvim/lua/snacks/layout.lua

558 lines
15 KiB
Lua

---@class snacks.layout
---@field opts snacks.layout.Config
---@field root snacks.win
---@field wins table<string, snacks.win|{enabled?:boolean, layout?:boolean}>
---@field box_wins snacks.win[]
---@field win_opts table<string, snacks.win.Config>
---@field closed? boolean
---@field split? boolean
---@field screenpos number[]?
local M = {}
M.__index = M
M.meta = {
desc = "Window layouts",
}
---@class snacks.layout.Win: snacks.win.Config,{}
---@field depth? number
---@field win string layout window name
---@class snacks.layout.Box: snacks.layout.Win,{}
---@field box "horizontal" | "vertical"
---@field id? number
---@field [number] snacks.layout.Win | snacks.layout.Box children
---@alias snacks.layout.Widget snacks.layout.Win | snacks.layout.Box
---@class snacks.layout.Config
---@field show? boolean show the layout on creation (default: true)
---@field wins table<string, snacks.win> windows to include in the layout
---@field layout snacks.layout.Box layout definition
---@field fullscreen? boolean open in fullscreen
---@field hidden? string[] list of windows that will be excluded from the layout (but can be toggled)
---@field on_update? fun(layout: snacks.layout)
---@field on_update_pre? fun(layout: snacks.layout)
local defaults = {
layout = {
width = 0.6,
height = 0.6,
zindex = 50,
},
}
---@param opts snacks.layout.Config
function M.new(opts)
local self = setmetatable({}, M)
self.opts = vim.tbl_extend("force", defaults, opts)
self.win_opts = {}
self.wins = self.opts.wins or {}
self.box_wins = {}
local zindex = self.opts.layout.zindex or 50
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.w[win].snacks_layout then
local winc = vim.api.nvim_win_get_config(win)
if winc.zindex and winc.zindex >= zindex then
zindex = winc.zindex + 1
end
end
end
self.opts.layout.zindex = zindex + 2
-- wrap the split layout in a vertical box
-- this is needed since a simple split window can't have borders/titles
if self.opts.layout.position and self.opts.layout.position ~= "float" then
self.split = true
local inner = self.opts.layout
self.opts.layout = {
zindex = 30,
box = "vertical",
position = inner.position,
width = inner.width,
height = inner.height,
backdrop = inner.backdrop,
inner,
}
inner.width, inner.height, inner.col, inner.row, inner.position = 0, 0, 0, 0, nil
end
-- assign ids to boxes and create box wins if needed
local id = 1
self:each(function(box, parent)
box.depth = (parent and parent.depth + 1) or 0
if box.box then
---@cast box snacks.layout.Box
box.id, id = id, id + 1
local has_border = box.border and box.border ~= "" and box.border ~= "none"
local is_root = box.id == 1
if is_root or has_border then
local backdrop = false
if is_root then
backdrop = nil
end
self.box_wins[box.id] = Snacks.win(Snacks.win.resolve(box, {
relative = is_root and (box.relative or "editor") or "win",
focusable = false,
enter = false,
show = false,
resize = false,
noautocmd = true,
backdrop = backdrop,
zindex = (self.opts.layout.zindex or 50) + box.depth,
bo = { filetype = "snacks_layout_box", buftype = "nofile" },
w = { snacks_layout = true },
border = box.border,
}))
end
end
end)
self.root = self.box_wins[1]
assert(self.root, "no root box found")
for w, win in pairs(self.wins) do
self.win_opts[w] = vim.deepcopy(win.opts)
if win.opts.relative == "win" then
win.layout = false
end
end
-- close layout when any win is closed
self.root:on("WinClosed", function(_, ev)
if self.closed then
return true
end
local wid = tonumber(ev.match)
for _, win in pairs(self:get_wins()) do
if win.win == wid then
self:close()
return true
end
end
end)
self.root:on("WinResized", function(_, ev)
if self.closed then
return true
end
if not self.root:on_current_tab() then
return
end
local sp = vim.fn.screenpos(self.root.win, 1, 1)
if not vim.deep_equal(sp, self.screenpos) then
self.screenpos = sp
return self:update()
elseif vim.tbl_contains(vim.v.event.windows, self.root.win) then
return self:update()
end
end)
-- update layout on VimResized
self.root:on("VimResized", function()
if not self.root:on_current_tab() then
return
end
self:update()
end)
if self.opts.show ~= false then
vim.schedule(function()
self:show()
end)
end
return self
end
---@param cb fun(widget: snacks.layout.Widget, parent?: snacks.layout.Box)
---@param opts? {wins?:boolean, boxes?:boolean, box?:snacks.layout.Box}
function M:each(cb, opts)
opts = opts or {}
---@param widget snacks.layout.Widget
---@param parent? snacks.layout.Box
local function _each(widget, parent)
if widget.box then
if opts.boxes ~= false then
cb(widget, parent)
end
---@cast widget snacks.layout.Box
for _, child in ipairs(widget) do
_each(child, widget)
end
elseif opts.wins ~= false then
cb(widget, parent)
end
end
_each(opts.box or self.opts.layout)
end
---@param win string
function M:needs_layout(win)
local w = self.wins[win]
return w and w.layout ~= false and not self:is_hidden(win)
end
--- Check if a window is hidden
---@param win string
function M:is_hidden(win)
return self.opts.hidden and vim.tbl_contains(self.opts.hidden, win)
end
--- Toggle a window
---@param win string
---@param enable? boolean
---@param on_update? fun(enabled: boolean) called when the layout will be updated
function M:toggle(win, enable, on_update)
self.opts.hidden = self.opts.hidden or {}
local enabled = not self:is_hidden(win)
if enable == nil then
enable = not enabled
end
if enable == enabled then
return
end
if enable then
self.opts.hidden = vim.tbl_filter(function(w)
return w ~= win
end, self.opts.hidden)
else
table.insert(self.opts.hidden, win)
end
if on_update then
on_update(enable)
end
self:update()
end
---@package
function M:update()
if self.closed then
return
end
vim.o.lazyredraw = true
for _, win in pairs(self.wins) do
win.enabled = false
end
local layout = vim.deepcopy(self.opts.layout)
if self.opts.fullscreen then
layout.width = 0
layout.height = 0
layout.col = 0
layout.row = 0
end
if not self.root:valid() then
self.root:show()
self.screenpos = vim.fn.screenpos(self.root.win, 1, 1)
end
-- Calculate offsets for vertical splits
local top, bottom = 0, 0
local pos = self.opts.layout.position
if pos and (pos == "left" or pos == "right") or self.opts.fullscreen then
bottom = (vim.o.cmdheight + (vim.o.laststatus == 3 and 1 or 0)) or 0
top = (vim.o.showtabline == 2 or (vim.o.showtabline == 1 and #vim.api.nvim_list_tabpages() > 1)) and 1 or 0
end
self:update_box(layout, {
col = 0,
row = self.opts.fullscreen and self.split and top or 0, -- only needed for fullscreen splits
width = vim.o.columns,
height = vim.o.lines - top - bottom,
})
-- fix fullscreen float layouts
if self.opts.fullscreen and not self.split then
self.root.opts.row = self.root.opts.row + top
end
if self.opts.on_update_pre then
self.opts.on_update_pre(self)
end
for _, win in pairs(self:get_wins()) do
if win:valid() then
-- update windows with eventignore=all
-- to fix issues with syntax being reset
local ei = vim.o.eventignore
vim.o.eventignore = "all"
win:update()
vim.o.eventignore = ei
else
win:show()
end
end
for w, win in pairs(self.wins) do
if not self:is_enabled(w) and win:win_valid() then
win:close()
end
end
vim.o.lazyredraw = false
if self.opts.on_update then
self.opts.on_update(self)
end
end
---@param box snacks.layout.Box
---@param parent snacks.win.Dim
---@private
function M:update_box(box, parent)
local size_main = box.box == "horizontal" and "width" or "height"
local pos_main = box.box == "horizontal" and "col" or "row"
local is_root = box.id == 1
if not is_root then
box.col = box.col or 0
box.row = box.row or 0
end
local children = {} ---@type snacks.layout.Widget[]
for c, child in ipairs(box) do
if not child.win or self:needs_layout(child.win) then
children[#children + 1] = child
end
box[c] = nil
end
for c, child in ipairs(children) do
box[c] = child
end
local dim, border = self:dim_box(box, parent)
local orig_dim = vim.deepcopy(dim)
if is_root then
dim.col = parent.col
dim.row = parent.row
else
dim.col = dim.col + border.left + parent.col
dim.row = dim.row + border.top + parent.row
end
local free = vim.deepcopy(dim)
local function size(child)
return child[size_main] or 0
end
local dims = {} ---@type table<number, snacks.win.Dim>
local flex = 0
-- fixed
for c, child in ipairs(box) do
if size(child) > 0 then
dims[c] = self:resolve(child, dim)
free[size_main] = free[size_main] - dims[c][size_main]
else
flex = flex + 1
end
end
-- flex
local free_main = free[size_main]
for c, child in ipairs(box) do
if not dims[c] then
free[size_main] = math.floor(free_main / flex)
flex = flex - 1
free_main = free_main - free[size_main]
dims[c] = self:resolve(child, free)
end
end
-- fix positions
local offset = 0
for c, child in ipairs(box) do
dims[c][pos_main] = offset
local wins = self:get_wins(child, { layout = true })
for _, win in ipairs(wins) do
win.opts[pos_main] = win.opts[pos_main] + offset
end
offset = offset + dims[c][size_main]
end
-- update box win
local box_win = self.box_wins[box.id]
if box_win then
if not is_root then
box_win.opts.win = self.root.win
end
box_win.opts.col = parent.col + orig_dim.col
box_win.opts.row = parent.row + orig_dim.row
box_win.opts.width = orig_dim.width
box_win.opts.height = orig_dim.height
end
-- return outer dimensions
orig_dim.width = orig_dim.width + border.left + border.right
orig_dim.height = orig_dim.height + border.top + border.bottom
return orig_dim
end
---@param widget? snacks.layout.Widget
---@param opts? {layout: boolean}
---@package
function M:get_wins(widget, opts)
opts = opts or {}
local ret = {} ---@type snacks.win[]
self:each(function(w)
if w.box and self.box_wins[w.id] then
table.insert(ret, self.box_wins[w.id])
elseif w.win and self:is_enabled(w.win) then
local win = self.wins[w.win]
if not (opts.layout and win.layout == false) then
table.insert(ret, self.wins[w.win])
end
end
end, { box = widget })
return ret
end
---@param widget snacks.layout.Widget
---@param parent snacks.win.Dim
---@private
function M:resolve(widget, parent)
if widget.box then
---@cast widget snacks.layout.Box
return self:update_box(widget, parent)
else
assert(widget.win, "widget must have win or box")
---@cast widget snacks.layout.Win
return self:update_win(widget, parent)
end
end
---@param widget snacks.layout.Box
---@param parent snacks.win.Dim
---@private
function M:dim_box(widget, parent)
-- honor the actual window size for split layouts
if not self.opts.fullscreen and widget.id == 1 and self.split and self.root:valid() then
return {
height = vim.api.nvim_win_get_height(self.root.win) - (vim.wo[self.root.win].winbar == "" and 0 or 1),
width = vim.api.nvim_win_get_width(self.root.win),
col = 0,
row = 0,
}, { left = 0, right = 0, top = 0, bottom = 0 }
end
local opts = vim.deepcopy(widget) --[[@as snacks.win.Config]]
-- adjust max width / height
opts.max_width = math.min(parent.width, opts.max_width or parent.width)
opts.max_height = math.min(parent.height, opts.max_height or parent.height)
local fake_win = setmetatable({ opts = opts }, Snacks.win)
local ret = fake_win:dim(parent)
return ret, fake_win:border_size()
end
---@param win snacks.layout.Win
---@param parent snacks.win.Dim
---@private
function M:update_win(win, parent)
local w = self.wins[win.win]
w.enabled = true
assert(w, ("win %s not part of layout"):format(win.win))
-- add win opts from layout
w.opts = Snacks.config.merge(
vim.deepcopy(self.win_opts[win.win] or {}),
{
width = 0,
height = 0,
enter = false,
},
win,
{
relative = "win",
win = self.root.win,
backdrop = false,
resize = false,
zindex = (self.opts.layout.zindex or 50) + win.depth + 1,
w = { snacks_layout = true },
}
)
-- fix fullscreen for splits
if self.opts.fullscreen and self.split then
w.opts.relative = "editor"
w.opts.win = nil
end
-- adjust max width / height
w.opts.max_width = math.max(math.min(parent.width, w.opts.max_width or parent.width), 1)
w.opts.max_height = math.max(math.min(parent.height, w.opts.max_height or parent.height), 1)
-- resolve width / height relative to parent box
local dim = w:dim(parent)
w.opts.width, w.opts.height = dim.width, dim.height
local border = w:border_size()
w.opts.col, w.opts.row = parent.col, parent.row
dim.width = dim.width + border.left + border.right
dim.height = dim.height + border.top + border.bottom
-- dim.col = dim.col + border.left
-- dim.row = dim.row + border.top
return dim
end
--- Toggle fullscreen
function M:maximize()
self.opts.fullscreen = not self.opts.fullscreen
self:update()
end
--- Close the layout
---@param opts? {wins?: boolean}
function M:close(opts)
if self.closed then
return
end
opts = opts or {}
self.closed = true
for w, win in pairs(self.wins) do
if opts.wins == false then
win.opts = self.win_opts[w]
else
win:destroy()
end
end
for _, win in pairs(self.box_wins) do
win:destroy()
end
vim.schedule(function()
self.opts = nil
self.root = nil
self.wins = nil
self.box_wins = nil
self.win_opts = nil
end)
end
--- Check if layout is valid (visible)
function M:valid()
return not self.closed and self.root:valid()
end
--- Check if the window has been used in the layout
---@param w string
function M:is_enabled(w)
return not self:is_hidden(w) and (self.wins[w].enabled or self.wins[w].layout == false)
end
function M:hide()
for _, win in ipairs(self:get_wins()) do
if win:valid() then
vim.api.nvim_win_set_config(win.win, { hide = true })
if win.backdrop and win.backdrop:valid() then
vim.api.nvim_win_set_config(win.backdrop.win, { hide = true })
end
end
end
end
function M:unhide()
for _, win in ipairs(self:get_wins()) do
if win:valid() then
vim.api.nvim_win_set_config(win.win, { hide = false })
if win.backdrop and win.backdrop:valid() then
vim.api.nvim_win_set_config(win.backdrop.win, { hide = false })
end
end
end
end
--- Show the layout
function M:show()
if self:valid() then
return
end
self:update()
end
return M