mirror of
https://github.com/folke/snacks.nvim
synced 2025-07-07 21:25:11 +00:00
558 lines
15 KiB
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
|