mirror of
https://github.com/folke/snacks.nvim
synced 2025-07-24 21:44:12 +00:00

## The bug `backdrop` displays a bright cell at the top left. upon investigation, I figured out that it's created by the user's `colorcolumn` setting also being applied to the backdrop. ## The fix Disable `colorcolumn` for the backdrop window. ## Screenshots before  after 
908 lines
25 KiB
Lua
908 lines
25 KiB
Lua
---@class snacks.win
|
|
---@field id number
|
|
---@field buf? number
|
|
---@field win? number
|
|
---@field opts snacks.win.Config
|
|
---@field augroup? number
|
|
---@field backdrop? snacks.win
|
|
---@field keys snacks.win.Keys[]
|
|
---@field events (snacks.win.Event|{event:string|string[]})[]
|
|
---@overload fun(opts? :snacks.win.Config|{}): snacks.win
|
|
local M = setmetatable({}, {
|
|
__call = function(t, ...)
|
|
return t.new(...)
|
|
end,
|
|
})
|
|
M.__index = M
|
|
|
|
M.meta = {
|
|
desc = "Create and manage floating windows or splits",
|
|
}
|
|
|
|
---@class snacks.win.Keys: vim.api.keyset.keymap
|
|
---@field [1]? string
|
|
---@field [2]? string|string[]|fun(self: snacks.win): string?
|
|
---@field mode? string|string[]
|
|
|
|
---@class snacks.win.Event: vim.api.keyset.create_autocmd
|
|
---@field buf? true
|
|
---@field win? true
|
|
---@field callback? fun(self: snacks.win)
|
|
|
|
---@class snacks.win.Backdrop
|
|
---@field bg? string
|
|
---@field blend? number
|
|
---@field transparent? boolean defaults to true
|
|
---@field win? snacks.win.Config overrides the backdrop window config
|
|
|
|
---@class snacks.win.Dim
|
|
---@field width number width of the window, without borders
|
|
---@field height number height of the window, without borders
|
|
---@field row number row of the window (0-indexed)
|
|
---@field col number column of the window (0-indexed)
|
|
---@field border? boolean whether the window has a border
|
|
|
|
---@alias snacks.win.Action.fn fun(self: snacks.win):(boolean|string?)
|
|
---@alias snacks.win.Action.spec snacks.win.Action|snacks.win.Action.fn
|
|
---@class snacks.win.Action
|
|
---@field action snacks.win.Action.fn
|
|
---@field desc? string
|
|
|
|
---@class snacks.win.Config: vim.api.keyset.win_config
|
|
---@field style? string merges with config from `Snacks.config.styles[style]`
|
|
---@field show? boolean Show the window immediately (default: true)
|
|
---@field height? number|fun(self:snacks.win):number Height of the window. Use <1 for relative height. 0 means full height. (default: 0.9)
|
|
---@field width? number|fun(self:snacks.win):number Width of the window. Use <1 for relative width. 0 means full width. (default: 0.9)
|
|
---@field min_height? number Minimum height of the window
|
|
---@field max_height? number Maximum height of the window
|
|
---@field min_width? number Minimum width of the window
|
|
---@field max_width? number Maximum width of the window
|
|
---@field col? number|fun(self:snacks.win):number Column of the window. Use <1 for relative column. (default: center)
|
|
---@field row? number|fun(self:snacks.win):number Row of the window. Use <1 for relative row. (default: center)
|
|
---@field minimal? boolean Disable a bunch of options to make the window minimal (default: true)
|
|
---@field position? "float"|"bottom"|"top"|"left"|"right"
|
|
---@field border? "none"|"top"|"right"|"bottom"|"left"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false
|
|
---@field buf? number If set, use this buffer instead of creating a new one
|
|
---@field file? string If set, use this file instead of creating a new buffer
|
|
---@field enter? boolean Enter the window after opening (default: false)
|
|
---@field backdrop? number|false|snacks.win.Backdrop Opacity of the backdrop (default: 60)
|
|
---@field wo? vim.wo|{} window options
|
|
---@field bo? vim.bo|{} buffer options
|
|
---@field b? table<string, any> buffer local variables
|
|
---@field w? table<string, any> window local variables
|
|
---@field ft? string filetype to use for treesitter/syntax highlighting. Won't override existing filetype
|
|
---@field keys? table<string, false|string|fun(self: snacks.win)|snacks.win.Keys> Key mappings
|
|
---@field on_buf? fun(self: snacks.win) Callback after opening the buffer
|
|
---@field on_win? fun(self: snacks.win) Callback after opening the window
|
|
---@field on_close? fun(self: snacks.win) Callback after closing the window
|
|
---@field fixbuf? boolean don't allow other buffers to be opened in this window
|
|
---@field text? string|string[]|fun():(string[]|string) Initial lines to set in the buffer
|
|
---@field actions? table<string, snacks.win.Action.spec> Actions that can be used in key mappings
|
|
local defaults = {
|
|
show = true,
|
|
fixbuf = true,
|
|
relative = "editor",
|
|
position = "float",
|
|
minimal = true,
|
|
wo = {
|
|
winhighlight = "Normal:SnacksNormal,NormalNC:SnacksNormalNC,WinBar:SnacksWinBar,WinBarNC:SnacksWinBarNC",
|
|
},
|
|
bo = {},
|
|
keys = {
|
|
q = "close",
|
|
},
|
|
}
|
|
|
|
Snacks.config.style("float", {
|
|
position = "float",
|
|
backdrop = 60,
|
|
height = 0.9,
|
|
width = 0.9,
|
|
zindex = 50,
|
|
})
|
|
|
|
Snacks.config.style("split", {
|
|
position = "bottom",
|
|
height = 0.4,
|
|
width = 0.4,
|
|
})
|
|
|
|
Snacks.config.style("minimal", {
|
|
wo = {
|
|
cursorcolumn = false,
|
|
cursorline = false,
|
|
cursorlineopt = "both",
|
|
fillchars = "eob: ,lastline:…",
|
|
list = false,
|
|
listchars = "extends:…,tab: ",
|
|
number = false,
|
|
relativenumber = false,
|
|
signcolumn = "no",
|
|
spell = false,
|
|
winbar = "",
|
|
statuscolumn = "",
|
|
wrap = false,
|
|
sidescrolloff = 0,
|
|
},
|
|
})
|
|
|
|
local SCROLL_UP, SCROLL_DOWN = Snacks.util.keycode("<c-u>"), Snacks.util.keycode("<c-d>")
|
|
|
|
local split_commands = {
|
|
editor = {
|
|
top = "topleft",
|
|
right = "vertical botright",
|
|
bottom = "botright",
|
|
left = "vertical topleft",
|
|
},
|
|
win = {
|
|
top = "aboveleft",
|
|
right = "vertical rightbelow",
|
|
bottom = "belowright",
|
|
left = "vertical leftabove",
|
|
},
|
|
}
|
|
|
|
local win_opts = {
|
|
"anchor",
|
|
"border",
|
|
"bufpos",
|
|
"col",
|
|
"external",
|
|
"fixed",
|
|
"focusable",
|
|
"footer",
|
|
"footer_pos",
|
|
"height",
|
|
"hide",
|
|
"noautocmd",
|
|
"relative",
|
|
"row",
|
|
"style",
|
|
"title",
|
|
"title_pos",
|
|
"width",
|
|
"win",
|
|
"zindex",
|
|
}
|
|
|
|
---@type table<string, string[]>
|
|
local borders = {
|
|
left = { "│", "", "", "", "", "", "│", "│" },
|
|
right = { "", "", "│", "│", "│", "", "", "" },
|
|
top = { "─", "─", "─", "", "", "", "", "" },
|
|
bottom = { "", "", "", "", "─", "─", "─", "" },
|
|
}
|
|
|
|
Snacks.util.set_hl({
|
|
Backdrop = { bg = "#000000" },
|
|
Normal = "NormalFloat",
|
|
NormalNC = "NormalFloat",
|
|
WinBar = "Title",
|
|
WinBarNC = "SnacksWinBar",
|
|
}, { prefix = "Snacks", default = true })
|
|
|
|
local id = 0
|
|
|
|
--@private
|
|
---@param ...? snacks.win.Config|string|{}
|
|
---@return snacks.win.Config
|
|
function M.resolve(...)
|
|
local done = {} ---@type table<string, boolean>
|
|
local merge = {} ---@type snacks.win.Config[]
|
|
local stack = {}
|
|
for i = 1, select("#", ...) do
|
|
local next = select(i, ...) ---@type snacks.win.Config|string?
|
|
if next then
|
|
table.insert(stack, next)
|
|
end
|
|
end
|
|
while #stack > 0 do
|
|
local next = table.remove(stack)
|
|
next = type(next) == "string" and Snacks.config.styles[next] or next
|
|
---@cast next snacks.win.Config?
|
|
if next and type(next) == "table" then
|
|
table.insert(merge, 1, next)
|
|
if next.style and not done[next.style] then
|
|
done[next.style] = true
|
|
table.insert(stack, next.style)
|
|
end
|
|
end
|
|
end
|
|
local ret = #merge == 0 and {} or #merge == 1 and merge[1] or vim.tbl_deep_extend("force", {}, unpack(merge))
|
|
ret.style = nil
|
|
return ret
|
|
end
|
|
|
|
---@param opts? snacks.win.Config|{}
|
|
---@return snacks.win
|
|
function M.new(opts)
|
|
local self = setmetatable({}, M)
|
|
id = id + 1
|
|
self.id = id
|
|
opts = M.resolve(Snacks.config.get("win", defaults), opts)
|
|
if opts.minimal then
|
|
opts = M.resolve("minimal", opts)
|
|
end
|
|
if opts.position == "float" then
|
|
opts = M.resolve("float", opts)
|
|
else
|
|
opts = M.resolve("split", opts)
|
|
local vertical = opts.position == "left" or opts.position == "right"
|
|
opts.wo.winfixheight = not vertical
|
|
opts.wo.winfixwidth = vertical
|
|
end
|
|
if opts.relative == "win" then
|
|
opts.win = opts.win or vim.api.nvim_get_current_win()
|
|
end
|
|
|
|
self.keys = {}
|
|
self.events = {}
|
|
for key, spec in pairs(opts.keys) do
|
|
if spec then
|
|
if type(spec) == "string" then
|
|
spec = { key, spec, desc = spec }
|
|
elseif type(spec) == "function" then
|
|
spec = { key, spec }
|
|
end
|
|
table.insert(self.keys, spec)
|
|
end
|
|
end
|
|
|
|
self:on("WinClosed", self.on_close, { win = true })
|
|
|
|
-- update window size when resizing
|
|
self:on("VimResized", self.update)
|
|
|
|
---@cast opts snacks.win.Config
|
|
self.opts = opts
|
|
if opts.show ~= false then
|
|
self:show()
|
|
end
|
|
return self
|
|
end
|
|
|
|
---@param actions string|string[]
|
|
---@return (fun(): boolean|string?) action, string? desc
|
|
function M:action(actions)
|
|
actions = type(actions) == "string" and { actions } or actions
|
|
---@cast actions string[]
|
|
local desc = {} ---@type string[]
|
|
for a, name in ipairs(actions) do
|
|
desc[a] = name:gsub("_", " ")
|
|
if self.opts.actions and self.opts.actions[name] then
|
|
local action = self.opts.actions[name]
|
|
desc[a] = type(action) == "table" and action.desc and action.desc or desc[a]
|
|
end
|
|
end
|
|
return function()
|
|
for _, name in ipairs(actions) do
|
|
if self.opts.actions and self.opts.actions[name] then
|
|
local a = self.opts.actions[name]
|
|
local fn = type(a) == "function" and a or a.action
|
|
local ret = fn(self)
|
|
if ret then
|
|
return type(ret) == "string" and ret or nil
|
|
end
|
|
elseif self[name] then
|
|
self[name](self)
|
|
return
|
|
else
|
|
return name
|
|
end
|
|
end
|
|
end,
|
|
table.concat(desc, ", ")
|
|
end
|
|
|
|
---@param event string|string[]
|
|
---@param cb fun(self: snacks.win)
|
|
---@param opts? snacks.win.Event
|
|
function M:on(event, cb, opts)
|
|
opts = opts or {}
|
|
opts.callback = cb
|
|
table.insert(self.events, vim.tbl_extend("keep", { event = event }, opts))
|
|
if self:valid() then
|
|
self:_on(event, opts)
|
|
end
|
|
end
|
|
|
|
---@param event string|string[]
|
|
---@param opts snacks.win.Event
|
|
function M:_on(event, opts)
|
|
local event_opts = {} ---@type vim.api.keyset.create_autocmd
|
|
local skip = { "buf", "win", "event" }
|
|
for k, v in pairs(opts) do
|
|
if not vim.tbl_contains(skip, k) then
|
|
event_opts[k] = v
|
|
end
|
|
end
|
|
event_opts.group = event_opts.group or self.augroup
|
|
event_opts.callback = function()
|
|
opts.callback(self)
|
|
end
|
|
if event_opts.pattern or event_opts.buffer then
|
|
-- don't alter the pattern or buffer
|
|
elseif opts.win then
|
|
event_opts.pattern = self.win .. ""
|
|
elseif opts.buf then
|
|
event_opts.buffer = self.buf
|
|
end
|
|
vim.api.nvim_create_autocmd(event, event_opts)
|
|
end
|
|
|
|
function M:focus()
|
|
if self:valid() then
|
|
vim.api.nvim_set_current_win(self.win)
|
|
end
|
|
end
|
|
|
|
function M:redraw()
|
|
if vim.api.nvim__redraw then
|
|
vim.api.nvim__redraw({ win = self.win, valid = false, flush = true })
|
|
else
|
|
vim.cmd("redraw")
|
|
end
|
|
end
|
|
|
|
---@param up? boolean
|
|
function M:scroll(up)
|
|
vim.api.nvim_buf_call(self.buf, function()
|
|
vim.cmd(("normal! %s"):format(up and SCROLL_UP or SCROLL_DOWN))
|
|
end)
|
|
end
|
|
|
|
---@param opts? { buf: boolean }
|
|
function M:close(opts)
|
|
opts = opts or {}
|
|
local wipe = opts.buf ~= false and not self.opts.buf and not self.opts.file
|
|
|
|
local win = self.win
|
|
local buf = wipe and self.buf
|
|
|
|
-- never close modified buffers
|
|
if buf and vim.bo[buf].modified and vim.bo[buf].buftype == "" then
|
|
if not pcall(vim.api.nvim_buf_delete, buf, { force = false }) then
|
|
return
|
|
end
|
|
end
|
|
|
|
self.win = nil
|
|
if buf then
|
|
self.buf = nil
|
|
end
|
|
local close = function()
|
|
if win and vim.api.nvim_win_is_valid(win) then
|
|
vim.api.nvim_win_close(win, true)
|
|
end
|
|
if buf and vim.api.nvim_buf_is_valid(buf) then
|
|
vim.api.nvim_buf_delete(buf, { force = true })
|
|
end
|
|
if self.augroup then
|
|
pcall(vim.api.nvim_del_augroup_by_id, self.augroup)
|
|
self.augroup = nil
|
|
end
|
|
end
|
|
local try_close
|
|
try_close = function()
|
|
local ok, err = pcall(close)
|
|
if not ok and err and err:find("E565") then
|
|
vim.defer_fn(try_close, 50)
|
|
end
|
|
end
|
|
vim.schedule(try_close)
|
|
end
|
|
|
|
function M:hide()
|
|
self:close({ buf = false })
|
|
return self
|
|
end
|
|
|
|
function M:toggle()
|
|
if self:valid() then
|
|
self:hide()
|
|
else
|
|
self:show()
|
|
end
|
|
return self
|
|
end
|
|
|
|
---@private
|
|
function M:open_buf()
|
|
if self.buf and vim.api.nvim_buf_is_valid(self.buf) then
|
|
-- keep existing buffer
|
|
self.buf = self.buf
|
|
elseif self.opts.file then
|
|
self.buf = vim.fn.bufadd(self.opts.file)
|
|
if not vim.api.nvim_buf_is_loaded(self.buf) then
|
|
vim.bo[self.buf].readonly = true
|
|
vim.bo[self.buf].swapfile = false
|
|
vim.fn.bufload(self.buf)
|
|
vim.bo[self.buf].modifiable = false
|
|
end
|
|
elseif self.opts.buf then
|
|
self.buf = self.opts.buf
|
|
else
|
|
self.buf = vim.api.nvim_create_buf(false, true)
|
|
local text = type(self.opts.text) == "function" and self.opts.text() or self.opts.text
|
|
text = type(text) == "string" and { text } or text
|
|
if text then
|
|
---@cast text string[]
|
|
vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, text)
|
|
end
|
|
end
|
|
if vim.bo[self.buf].filetype == "" and not self.opts.bo.filetype then
|
|
self.opts.bo.filetype = "snacks_win"
|
|
end
|
|
return self.buf
|
|
end
|
|
|
|
---@private
|
|
function M:open_win()
|
|
local relative = self.opts.relative or "editor"
|
|
local position = self.opts.position or "float"
|
|
local enter = self.opts.enter == nil or self.opts.enter or false
|
|
enter = not self.opts.focusable and enter or false
|
|
local opts = self:win_opts()
|
|
if position == "float" then
|
|
self.win = vim.api.nvim_open_win(self.buf, enter, opts)
|
|
else
|
|
local parent = self.opts.win or 0
|
|
local vertical = position == "left" or position == "right"
|
|
if parent == 0 then
|
|
for _, win in ipairs(vim.api.nvim_list_wins()) do
|
|
if
|
|
vim.w[win].snacks_win
|
|
and vim.w[win].snacks_win.relative == relative
|
|
and vim.w[win].snacks_win.position == position
|
|
then
|
|
parent = win
|
|
relative = "win"
|
|
position = vertical and "bottom" or "right"
|
|
vertical = not vertical
|
|
break
|
|
end
|
|
end
|
|
end
|
|
local cmd = split_commands[relative][position]
|
|
local size = vertical and opts.width or opts.height
|
|
vim.api.nvim_win_call(parent, function()
|
|
vim.cmd("silent noswapfile " .. cmd .. " " .. size .. "split")
|
|
vim.api.nvim_win_set_buf(0, self.buf)
|
|
self.win = vim.api.nvim_get_current_win()
|
|
end)
|
|
if enter then
|
|
vim.api.nvim_set_current_win(self.win)
|
|
end
|
|
vim.schedule(function()
|
|
self:equalize()
|
|
end)
|
|
end
|
|
vim.w[self.win].snacks_win = {
|
|
id = self.id,
|
|
position = self.opts.position,
|
|
relative = self.opts.relative,
|
|
}
|
|
end
|
|
|
|
---@private
|
|
function M:equalize()
|
|
if self:is_floating() then
|
|
return
|
|
end
|
|
local all = vim.tbl_filter(function(win)
|
|
return vim.w[win].snacks_win
|
|
and vim.w[win].snacks_win.relative == self.opts.relative
|
|
and vim.w[win].snacks_win.position == self.opts.position
|
|
end, vim.api.nvim_list_wins())
|
|
if #all <= 1 then
|
|
return
|
|
end
|
|
local vertical = self.opts.position == "left" or self.opts.position == "right"
|
|
local parent_size = self:parent_size()[vertical and "height" or "width"]
|
|
local size = math.floor(parent_size / #all)
|
|
for _, win in ipairs(all) do
|
|
vim.api.nvim_win_call(win, function()
|
|
vim.cmd(("%s resize %s"):format(vertical and "horizontal" or "vertical", size))
|
|
end)
|
|
end
|
|
end
|
|
|
|
function M:update()
|
|
if self:valid() then
|
|
Snacks.util.bo(self.buf, self.opts.bo)
|
|
Snacks.util.wo(self.win, self.opts.wo)
|
|
if self:is_floating() then
|
|
local opts = self:win_opts()
|
|
opts.noautocmd = nil
|
|
vim.api.nvim_win_set_config(self.win, opts)
|
|
end
|
|
end
|
|
end
|
|
|
|
function M:show()
|
|
if self:valid() then
|
|
self:update()
|
|
return self
|
|
end
|
|
self.augroup = vim.api.nvim_create_augroup("snacks_win_" .. self.id, { clear = true })
|
|
|
|
self:open_buf()
|
|
|
|
-- buffer local variables
|
|
for k, v in pairs(self.opts.b or {}) do
|
|
vim.b[self.buf][k] = v
|
|
end
|
|
|
|
-- OPTIM: prevent treesitter or syntax highlighting to attach on FileType if it's not already enabled
|
|
local optim_hl = not vim.b[self.buf].ts_highlight and vim.bo[self.buf].syntax == ""
|
|
vim.b[self.buf].ts_highlight = optim_hl or vim.b[self.buf].ts_highlight
|
|
Snacks.util.bo(self.buf, self.opts.bo)
|
|
vim.b[self.buf].ts_highlight = not optim_hl and vim.b[self.buf].ts_highlight or nil
|
|
|
|
if self.opts.on_buf then
|
|
self.opts.on_buf(self)
|
|
end
|
|
|
|
self:open_win()
|
|
-- window local variables
|
|
for k, v in pairs(self.opts.w or {}) do
|
|
vim.w[self.win][k] = v
|
|
end
|
|
if Snacks.util.is_transparent() then
|
|
self.opts.wo.winblend = 0
|
|
end
|
|
Snacks.util.wo(self.win, self.opts.wo)
|
|
if self.opts.on_win then
|
|
self.opts.on_win(self)
|
|
end
|
|
|
|
-- syntax highlighting
|
|
local ft = self.opts.ft or vim.bo[self.buf].filetype
|
|
if ft and not ft:find("^snacks_") and not vim.b[self.buf].ts_highlight and vim.bo[self.buf].syntax == "" then
|
|
local lang = vim.treesitter.language.get_lang(ft)
|
|
if not (lang and pcall(vim.treesitter.start, self.buf, lang)) then
|
|
vim.bo[self.buf].syntax = ft
|
|
end
|
|
end
|
|
|
|
for _, event in ipairs(self.events) do
|
|
self:_on(event.event, event)
|
|
end
|
|
|
|
-- swap buffers when opening a new buffer in the same window
|
|
vim.api.nvim_create_autocmd("BufWinEnter", {
|
|
group = self.augroup,
|
|
callback = function()
|
|
-- window closes, so delete the autocmd
|
|
if not self:win_valid() then
|
|
return true
|
|
end
|
|
|
|
local buf = vim.api.nvim_win_get_buf(self.win)
|
|
|
|
-- same buffer
|
|
if buf == self.buf then
|
|
return
|
|
end
|
|
|
|
-- don't swap if fixbuf is disabled
|
|
if self.opts.fixbuf == false then
|
|
self.buf = buf
|
|
-- update window options
|
|
Snacks.util.wo(self.win, self.opts.wo)
|
|
return
|
|
end
|
|
|
|
-- another buffer was opened in this window
|
|
-- find another window to swap with
|
|
for _, win in ipairs(vim.api.nvim_list_wins()) do
|
|
if win ~= self.win and vim.bo[vim.api.nvim_win_get_buf(win)].buftype == "" then
|
|
vim.schedule(function()
|
|
vim.api.nvim_win_set_buf(self.win, self.buf)
|
|
vim.api.nvim_win_set_buf(win, buf)
|
|
vim.api.nvim_set_current_win(win)
|
|
vim.cmd.stopinsert()
|
|
end)
|
|
return
|
|
end
|
|
end
|
|
end,
|
|
})
|
|
|
|
for _, spec in pairs(self.keys) do
|
|
local opts = vim.deepcopy(spec)
|
|
opts[1] = nil
|
|
opts[2] = nil
|
|
opts.mode = nil
|
|
---@diagnostic disable-next-line: cast-type-mismatch
|
|
---@cast opts vim.keymap.set.Opts
|
|
opts.buffer = self.buf
|
|
opts.nowait = true
|
|
local rhs = spec[2]
|
|
local is_action = type(rhs) == "string" or type(rhs) == "table"
|
|
if is_action then
|
|
local desc = spec.desc
|
|
---@cast rhs string|string[]
|
|
rhs, desc = self:action(rhs)
|
|
opts.desc = opts.desc or desc
|
|
else
|
|
rhs = function()
|
|
return spec[2](self)
|
|
end
|
|
end
|
|
---@cast spec snacks.win.Keys
|
|
vim.keymap.set(spec.mode or "n", spec[1], rhs, opts)
|
|
end
|
|
|
|
self:drop()
|
|
|
|
return self
|
|
end
|
|
|
|
---@private
|
|
function M:on_close()
|
|
-- close the backdrop
|
|
if self.backdrop then
|
|
self.backdrop:close()
|
|
self.backdrop = nil
|
|
end
|
|
if self.opts.on_close then
|
|
self.opts.on_close(self)
|
|
end
|
|
-- Go back to the previous window when closing,
|
|
-- and it's the current window
|
|
if vim.api.nvim_get_current_win() == self.win then
|
|
pcall(vim.cmd.wincmd, "p")
|
|
end
|
|
end
|
|
|
|
function M:add_padding()
|
|
local listchars = vim.split(self.opts.wo.listchars or "", ",")
|
|
listchars = vim.tbl_filter(function(s)
|
|
return not s:find("eol:")
|
|
end, listchars)
|
|
table.insert(listchars, "eol: ")
|
|
self.opts.wo.listchars = table.concat(listchars, ",")
|
|
self.opts.wo.list = true
|
|
self.opts.wo.statuscolumn = " "
|
|
end
|
|
|
|
function M:is_floating()
|
|
return self:valid() and vim.api.nvim_win_get_config(self.win).zindex ~= nil
|
|
end
|
|
|
|
---@private
|
|
function M:drop()
|
|
local backdrop = self.opts.backdrop
|
|
if not backdrop then
|
|
return
|
|
end
|
|
backdrop = type(backdrop) == "number" and { blend = backdrop } or backdrop
|
|
backdrop = backdrop == true and {} or backdrop
|
|
backdrop = vim.tbl_extend("force", { bg = "#000000", blend = 60, transparent = true }, backdrop)
|
|
---@cast backdrop snacks.win.Backdrop
|
|
|
|
if
|
|
(Snacks.util.is_transparent() and backdrop.transparent)
|
|
or not vim.o.termguicolors
|
|
or backdrop.blend == 100
|
|
or not self:is_floating()
|
|
then
|
|
return
|
|
end
|
|
|
|
local bg, winblend = backdrop.bg or "#000000", backdrop.blend
|
|
if not backdrop.transparent then
|
|
if Snacks.util.is_transparent() then
|
|
bg = nil
|
|
else
|
|
bg = Snacks.util.blend(Snacks.util.color("Normal", "bg"), bg, winblend / 100)
|
|
end
|
|
winblend = 0
|
|
end
|
|
|
|
local group = ("SnacksBackdrop_%s"):format(bg and bg:sub(2) or "T")
|
|
vim.api.nvim_set_hl(0, group, { bg = bg })
|
|
|
|
self.backdrop = M.new(M.resolve({
|
|
enter = false,
|
|
backdrop = false,
|
|
relative = "editor",
|
|
height = 0,
|
|
width = 0,
|
|
style = "minimal",
|
|
border = "none",
|
|
focusable = false,
|
|
zindex = self.opts.zindex - 1,
|
|
wo = {
|
|
winhighlight = "Normal:" .. group,
|
|
winblend = winblend,
|
|
colorcolumn = "",
|
|
},
|
|
bo = {
|
|
buftype = "nofile",
|
|
filetype = "snacks_win_backdrop",
|
|
},
|
|
}, backdrop.win))
|
|
end
|
|
|
|
function M:line(line)
|
|
return self:lines(line, line)[1] or ""
|
|
end
|
|
|
|
---@param from? number 1-indexed, inclusive
|
|
---@param to? number 1-indexed, inclusive
|
|
function M:lines(from, to)
|
|
return self:buf_valid() and vim.api.nvim_buf_get_lines(self.buf, from and from - 1 or 0, to or -1, false) or {}
|
|
end
|
|
|
|
---@param from? number 1-indexed, inclusive
|
|
---@param to? number 1-indexed, inclusive
|
|
function M:text(from, to)
|
|
return table.concat(self:lines(from, to), "\n")
|
|
end
|
|
|
|
---@return { height: number, width: number }
|
|
function M:parent_size()
|
|
return {
|
|
height = self.opts.relative == "win" and vim.api.nvim_win_get_height(self.opts.win) or vim.o.lines,
|
|
width = self.opts.relative == "win" and vim.api.nvim_win_get_width(self.opts.win) or vim.o.columns,
|
|
}
|
|
end
|
|
|
|
---@private
|
|
function M:win_opts()
|
|
local opts = {} ---@type vim.api.keyset.win_config
|
|
for _, k in ipairs(win_opts) do
|
|
opts[k] = self.opts[k]
|
|
end
|
|
|
|
opts.border = opts.border and (borders[opts.border] or opts.border) or "none"
|
|
|
|
if opts.relative == "cursor" then
|
|
self.opts.row = self.opts.row or 0
|
|
self.opts.col = self.opts.col or 0
|
|
end
|
|
|
|
local dim = self:dim()
|
|
opts.height, opts.width = dim.height, dim.width
|
|
opts.row, opts.col = dim.row, dim.col
|
|
|
|
if opts.title_pos and not opts.title then
|
|
opts.title_pos = nil
|
|
end
|
|
if opts.footer_pos and not opts.footer then
|
|
opts.footer_pos = nil
|
|
end
|
|
|
|
if vim.fn.has("nvim-0.10") == 0 then
|
|
opts.footer, opts.footer_pos = nil, nil
|
|
end
|
|
|
|
if not self:has_border() then
|
|
opts.title, opts.footer = nil, nil
|
|
opts.title_pos, opts.footer_pos = nil, nil
|
|
end
|
|
return opts
|
|
end
|
|
|
|
---@return { height: number, width: number }
|
|
function M:size()
|
|
local opts = self:win_opts()
|
|
local height = opts.height
|
|
local width = opts.width
|
|
if self:has_border() then
|
|
height = height + 2
|
|
width = width + 2
|
|
end
|
|
return { height = height, width = width }
|
|
end
|
|
|
|
function M:has_border()
|
|
return self.opts.border and self.opts.border ~= "" and self.opts.border ~= "none"
|
|
end
|
|
|
|
--- Calculate the size of the border
|
|
function M:border_size()
|
|
local border = self.opts.border and self.opts.border ~= "" and self.opts.border ~= "none" and self.opts.border
|
|
local full = border and not vim.tbl_contains({ "top", "right", "bottom", "left" }, border)
|
|
---@type { top: number, right: number, bottom: number, left: number }
|
|
return {
|
|
top = (full or border == "top") and 1 or 0,
|
|
right = (full or border == "right") and 1 or 0,
|
|
bottom = (full or border == "bottom") and 1 or 0,
|
|
left = (full or border == "left") and 1 or 0,
|
|
}
|
|
end
|
|
|
|
function M:border_text_width()
|
|
if not self:has_border() then
|
|
return 0
|
|
end
|
|
local ret = 0
|
|
for _, t in ipairs({ "title", "footer" }) do
|
|
local str = self.opts[t] or {}
|
|
str = type(str) == "string" and { str } or str
|
|
---@cast str (string|string[])[]
|
|
ret = math.max(ret, #table.concat(
|
|
vim.tbl_map(function(s)
|
|
return type(s) == "string" and s or s[1]
|
|
end, str),
|
|
""
|
|
))
|
|
end
|
|
return ret
|
|
end
|
|
|
|
function M:buf_valid()
|
|
return self.buf and vim.api.nvim_buf_is_valid(self.buf)
|
|
end
|
|
|
|
function M:win_valid()
|
|
return self.win and vim.api.nvim_win_is_valid(self.win)
|
|
end
|
|
|
|
function M:valid()
|
|
return self:win_valid() and self:buf_valid() and vim.api.nvim_win_get_buf(self.win) == self.buf
|
|
end
|
|
|
|
---@param parent? snacks.win.Dim
|
|
function M:dim(parent)
|
|
parent = parent or self:parent_size()
|
|
---@type snacks.win.Dim
|
|
local ret = {
|
|
height = 0,
|
|
width = 0,
|
|
col = 0,
|
|
row = 0,
|
|
border = self:has_border(),
|
|
}
|
|
|
|
---@param s? number|fun(win:snacks.win):number? size
|
|
---@param ps number parent size
|
|
local function size(s, ps, border_offset)
|
|
s = type(s) == "function" and s(self) or s or 0
|
|
---@cast s number
|
|
if s == 0 then -- full size
|
|
return ps - border_offset
|
|
elseif s < 1 then -- relative size
|
|
return math.floor(ps * s) - border_offset
|
|
end
|
|
return s
|
|
end
|
|
|
|
---@param p? number|fun(win:snacks.win):number? pos
|
|
---@param s number size
|
|
---@param ps number parent size
|
|
local function pos(p, s, ps, border_offset)
|
|
p = type(p) == "function" and p(self) or p
|
|
if not p then -- center
|
|
return math.floor((ps - s) / 2) + border_offset
|
|
end
|
|
---@cast p number
|
|
if p < 0 then -- negative position
|
|
return ps + p - border_offset
|
|
elseif p < 1 and p > 0 then -- relative position
|
|
return math.floor(ps * p) + border_offset
|
|
end
|
|
return p
|
|
end
|
|
|
|
local border = self:border_size()
|
|
|
|
ret.height = size(self.opts.height, parent.height, border.top + border.bottom)
|
|
ret.height = math.max(ret.height, self.opts.min_height or 0, 1)
|
|
ret.height = math.min(ret.height, self.opts.max_height or ret.height, parent.height)
|
|
|
|
ret.width = size(self.opts.width, parent.width, border.left + border.right)
|
|
ret.width = math.max(ret.width, self.opts.min_width or 0, 1)
|
|
ret.width = math.min(ret.width, self.opts.max_width or ret.width, parent.width)
|
|
|
|
ret.row = pos(self.opts.row, ret.height, parent.height, border.top)
|
|
ret.col = pos(self.opts.col, ret.width, parent.width, border.left)
|
|
|
|
return ret
|
|
end
|
|
|
|
return M
|