---@class snacks.win ---@field id number ---@field buf? number ---@field scratch_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[]})[] ---@field meta table ---@field closed? boolean ---@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, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ---@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"|"hpad"|"vpad"|"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 buffer local variables ---@field w? table window local variables ---@field ft? string filetype to use for treesitter/syntax highlighting. Won't override existing filetype ---@field scratch_ft? string filetype to use for scratch buffers ---@field keys? table 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 Actions that can be used in key mappings ---@field resize? boolean Automatically resize the window when the editor is resized 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("help", { position = "float", backdrop = false, border = "top", row = -1, width = 0, height = 0.3, }) Snacks.config.style("split", { position = "bottom", height = 0.4, width = 0.4, }) Snacks.config.style("minimal", { wo = { cursorcolumn = false, cursorline = false, cursorlineopt = "both", colorcolumn = "", 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(""), Snacks.util.keycode("") 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 local borders = { left = { "", "", "", "", "", "", "", "│" }, right = { "", "", "", "│", "", "", "", "" }, top = { "", "─", "", "", "", "", "", "" }, bottom = { "", "", "", "", "", "─", "", "" }, hpad = { "", "", "", " ", "", "", "", " " }, vpad = { "", " ", "", "", "", " ", "", "" }, } Snacks.util.set_hl({ Backdrop = { bg = "#000000" }, Normal = "NormalFloat", NormalNC = "NormalFloat", WinBar = "Title", WinBarNC = "SnacksWinBar", WinKey = "Keyword", WinKeySep = "NonText", WinKeyDesc = "Function", }, { prefix = "Snacks", default = true }) local id = 0 local event_stack = {} ---@type string[] --@private ---@param ...? snacks.win.Config|string|{} ---@return snacks.win.Config function M.resolve(...) local done = {} ---@type table 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 self.meta = {} 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 = {} local done = {} ---@type table 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 } elseif type(spec) == "table" and spec[1] and not spec[2] then spec = vim.deepcopy(spec) -- deepcopy just in case spec[1], spec[2] = key, spec[1] end ---@cast spec snacks.win.Keys local lhs = Snacks.util.normkey(spec[1] or "") local mode = type(spec.mode) == "table" and spec.mode or { spec.mode or "n" } ---@cast mode string[] mode = #mode == 0 and { "n" } or mode for _, m in ipairs(mode) do local k = m .. ":" .. lhs if done[k] then Snacks.notify.warn( ("# Duplicate key mapping for `%s` mode=%s (check case):\n```lua\n%s\n```\n```lua\n%s\n```"):format( lhs, m, vim.inspect(done[k]), vim.inspect(spec) ) ) end done[k] = 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.on_resize) ---@cast opts snacks.win.Config self.opts = opts if opts.show ~= false then self:show() end return self end function M:on_resize() if self.opts.resize ~= false then self:update() end end ---@param actions string|string[] function M:execute(actions) return self:action(actions)() 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 opts? {col_width?: number, key_width?: number, win?: snacks.win.Config} function M:toggle_help(opts) opts = opts or {} local col_width, key_width = opts.col_width or 30, opts.key_width or 10 for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do local buf = vim.api.nvim_win_get_buf(win) if vim.bo[buf].filetype == "snacks_win_help" then vim.api.nvim_win_close(win, true) return end end local ns = vim.api.nvim_create_namespace("snacks.win.help") local win = M.new(M.resolve({ style = "help" }, opts.win or {}, { show = false, focusable = false, zindex = self.opts.zindex + 1, bo = { filetype = "snacks_win_help" }, })) self:on("WinClosed", function() win:close() end, { win = true }) self:on("BufLeave", function() win:close() end, { buf = true }) local dim = win:dim() -- NOTE: we use the actual buffer keymaps instead of self.keys, -- since we want to show all keymaps, not just the ones we've defined on the window local keys = {} ---@type vim.api.keyset.get_keymap[] vim.list_extend(keys, vim.api.nvim_buf_get_keymap(self.buf, "n")) vim.list_extend(keys, vim.api.nvim_buf_get_keymap(self.buf, "i")) table.sort(keys, function(a, b) return (a.desc or a.lhs or "") < (b.desc or b.lhs or "") end) local done = {} ---@type table keys = vim.tbl_filter(function(keymap) local key = Snacks.util.normkey(keymap.lhs or "") if done[key] or (keymap.desc and keymap.desc:find("which%-key")) then return false end done[key] = true return true end, keys) local cols = math.floor((dim.width - 1) / col_width) local rows = math.ceil(#keys / cols) win.opts.height = rows local help = {} ---@type {[1]:string, [2]:string}[][] local row, col = 0, 1 ---@param str string ---@param len number ---@param align? "left"|"right" local function trunc(str, len, align) local w = vim.api.nvim_strwidth(str) if w > len then return vim.fn.strcharpart(str, 0, len - 1) .. "…" end return align == "right" and (string.rep(" ", len - w) .. str) or (str .. string.rep(" ", len - w)) end for _, keymap in ipairs(keys) do local key = Snacks.util.normkey(keymap.lhs or "") row = row + 1 if row > rows then row, col = 1, col + 1 end help[row] = help[row] or {} vim.list_extend(help[row], { { trunc(key, key_width, "right"), "SnacksWinKey" }, { " " }, { "➜", "SnacksWinKeySep" }, { " " }, { trunc(keymap.desc or "", col_width - key_width - 3), "SnacksWinKeyDesc" }, }) end win:show() for l, line in ipairs(help) do vim.api.nvim_buf_set_lines(win.buf, l - 1, l, false, { "" }) vim.api.nvim_buf_set_extmark(win.buf, ns, l - 1, 0, { virt_text = line, virt_text_pos = "overlay", }) end end ---@param event string|string[] ---@param cb fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ---@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(ev) table.insert(event_stack, ev.event) local ok, err = pcall(opts.callback, self, ev) table.remove(event_stack) return not ok and error(err) or err 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, cursor = false }) else vim.cmd("redraw") end end ---@param left? boolean function M:hscroll(left) vim.api.nvim_win_call(self.win, function() vim.cmd(("normal! %s"):format(left and "zh" or "zl")) end) end ---@param up? boolean function M:scroll(up) vim.api.nvim_win_call(self.win, function() vim.cmd(("normal! %s"):format(up and SCROLL_UP or SCROLL_DOWN)) end) end function M:destroy() self:close() self.events = {} self.keys = {} self.meta = {} -- self.opts = {} end ---@param opts? { buf: boolean } function M:close(opts) opts = opts or {} local wipe = opts.buf ~= false and self.buf == self.scratch_buf local win = self.win local buf = wipe and self.buf local scratch_buf = self.scratch_buf ~= self.buf and self.scratch_buf or nil self:on_close() self.win = nil self.scratch_buf = nil if buf then self.buf = nil end local close = function() if win and vim.api.nvim_win_is_valid(win) then local ok, err = pcall(vim.api.nvim_win_close, win, true) if not ok and (err and err:find("E444")) then -- last window, so creat a split and close it again vim.cmd("silent! vsplit") pcall(vim.api.nvim_win_close, win, true) elseif not ok then error(err) end end if buf and vim.api.nvim_buf_is_valid(buf) then vim.api.nvim_buf_delete(buf, { force = true }) end if scratch_buf and vim.api.nvim_buf_is_valid(scratch_buf) then vim.api.nvim_buf_delete(scratch_buf, { force = true }) end if self.augroup then pcall(vim.api.nvim_del_augroup_by_id, self.augroup) self.augroup = nil end end local retries = 0 local try_close ---@type fun() try_close = function() local ok, err = pcall(close) if ok or not err then return end -- command window is open if err:find("E11") then vim.defer_fn(try_close, 200) return end -- text lock if err:find("E565") and retries < 20 then retries = retries + 1 vim.defer_fn(try_close, 50) return end if not ok then Snacks.notify.error("Failed to close window: " .. err) end end -- HACK: WinClosed is not recursive, so we need to schedule it -- if we're in a WinClosed event if vim.tbl_contains(event_stack, "WinClosed") or not pcall(close) then vim.schedule(try_close) end 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 ---@param title string|{[1]:string, [2]:string}[] ---@param pos? "center"|"left"|"right" function M:set_title(title, pos) if not self:has_border() then return end if type(title) == "string" then title = vim.trim(title) if title ~= "" then -- HACK: add extra space when last char is non word -- like for icons etc if not title:sub(-1):match("%w") then title = title .. " " end title = " " .. title .. " " end elseif #title == 0 then title = "" end pos = pos or self.opts.title_pos or "center" if vim.deep_equal(self.opts.title, title) and self.opts.title_pos == pos then return end self.opts.title = title self.opts.title_pos = pos if not self:valid() then return end -- Don't try to update if the relative window is invalid. -- It will be fixed once a full update is done. local relative_win = vim.api.nvim_win_get_config(self.win).win if relative_win and not vim.api.nvim_win_is_valid(relative_win) then return end vim.api.nvim_win_set_config(self.win, { title = self.opts.title, title_pos = self.opts.title_pos, }) 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.scratch_buf and vim.api.nvim_buf_is_valid(self.scratch_buf) then -- keep existing scratch buffer self.buf = self.scratch_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:scratch() end return self.buf end function M:scratch() if self.buf == self.scratch_buf and self:buf_valid() then return end self.buf = vim.api.nvim_create_buf(false, true) vim.bo[self.buf].swapfile = false self.scratch_buf = self.buf local text = type(self.opts.text) == "function" and self.opts.text() or self.opts.text text = type(text) == "string" and vim.split(text, "\n") or text if text then ---@cast text string[] vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, text) end if not self.opts.bo.filetype then if self.opts.scratch_ft then vim.bo[self.buf].filetype = self.opts.scratch_ft else vim.bo[self.buf].filetype = self.opts.bo.filetype or "snacks_win" end vim.bo[self.buf].syntax = "" end if self:win_valid() then vim.api.nvim_win_set_buf(self.win, self.buf) end 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 if self.opts.focusable == false then enter = false end local opts = self:win_opts() if position == "float" then self.win = vim.api.nvim_open_win(self.buf, enter, opts) elseif position == "current" then self.win = vim.api.nvim_get_current_win() 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_tabpage_list_wins(0)) 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_tabpage_list_wins(0)) 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:on_current_tab() return self:win_valid() and vim.api.nvim_get_current_tabpage() == vim.api.nvim_win_get_tabpage(self.win) 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() self.closed = false -- 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, nested = true, callback = function() return self:fixbuf() end, }) self:map() self:drop() return self end function M:fixbuf() -- window closes, so delete the autocmd if not self:win_valid() then return true end if not self:on_current_tab() then return 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 local main ---@type number? for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do local win_buf = vim.api.nvim_win_get_buf(win) local is_float = vim.api.nvim_win_get_config(win).zindex ~= nil if win ~= self.win and not is_float then if vim.bo[win_buf].buftype == "" or vim.b[win_buf].snacks_main or vim.w[win].snacks_main then main = win break end end end if main then vim.api.nvim_win_set_buf(self.win, self.buf) vim.api.nvim_win_set_buf(main, buf) vim.api.nvim_set_current_win(main) vim.cmd.stopinsert() else -- no main window found, so close this window vim.api.nvim_win_set_buf(self.win, self.buf) vim.schedule(function() vim.cmd.stopinsert() vim.cmd("sbuffer " .. buf) if self.win and vim.api.nvim_win_is_valid(self.win) then vim.api.nvim_win_close(self.win, true) end end) end end ---@param buf number function M:set_buf(buf) assert(self:valid(), "Window is not valid") self.buf = buf vim.api.nvim_win_set_buf(self.win, buf) Snacks.util.wo(self.win, self.opts.wo) end function M:map() if not self:buf_valid() then return 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 spec.desc = spec.desc or opts.desc ---@cast spec snacks.win.Keys vim.keymap.set(spec.mode or "n", spec[1], rhs, opts) end end ---@private function M:on_close() -- close the backdrop if self.backdrop then self.backdrop:close() self.backdrop = nil end if self.closed then return end self.closed = true 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:") and s ~= "" 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() if self.backdrop then self.backdrop:close() self.backdrop = nil end 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() -- The array specifies the eight -- chars building up the border in a clockwise fashion -- starting with the top-left corner. -- { "╔", "═" ,"╗", "║", "╝", "═", "╚", "║" } local border = self:has_border() and self.opts.border or { "" } border = type(border) == "string" and borders[border] or border border = type(border) == "string" and { "x" } or border assert(type(border) == "table", "Invalid border type") ---@cast border string[] while #border < 8 do vim.list_extend(border, border) end -- remove border hl groups border = vim.tbl_map(function(b) return type(b) == "table" and b[1] or b end, border) local function size(from, to) for i = from, to do if border[i] ~= "" then return 1 end end return 0 end ---@type { top: number, right: number, bottom: number, left: number } return { top = size(1, 3), right = size(3, 5), bottom = size(5, 7), left = math.max(size(7, 8), size(1, 1)), } 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_from, border_to) p = type(p) == "function" and p(self) or p ---@cast p number? if self.opts.relative == "cursor" then return p or 0 end if not p then -- center return math.floor((ps - s) / 2) - border_from end ---@cast p number if p < 0 then -- negative position return ps - s + p - border_from - border_to elseif p < 1 and p > 0 then -- relative position return math.floor(ps * p) + border_from 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.height = math.max(ret.height, 1) 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.width = math.max(ret.width, 1) ret.row = pos(self.opts.row, ret.height, parent.height, border.top, border.bottom) ret.col = pos(self.opts.col, ret.width, parent.width, border.left, border.right) return ret end return M