---@class snacks.notifier ---@overload fun(msg: string, level?: snacks.notifier.level|number, opts?: snacks.notifier.Notif.opts): number|string local M = setmetatable({}, { __call = function(t, ...) return t.notify(...) end, }) M.meta = { desc = "Pretty `vim.notify`", needs_setup = true, } local uv = vim.uv or vim.loop --- Render styles: --- * compact: use border for icon and title --- * minimal: no border, only icon and message --- * fancy: similar to the default nvim-notify style ---@alias snacks.notifier.style snacks.notifier.render|"compact"|"fancy"|"minimal" --- ### Notifications --- --- Notification options ---@class snacks.notifier.Notif.opts ---@field id? number|string ---@field msg? string ---@field level? number|snacks.notifier.level ---@field title? string ---@field icon? string ---@field timeout? number|boolean timeout in ms. Set to 0|false to keep until manually closed ---@field ft? string ---@field keep? fun(notif: snacks.notifier.Notif): boolean ---@field style? snacks.notifier.style ---@field opts? fun(notif: snacks.notifier.Notif) -- dynamic opts ---@field hl? snacks.notifier.hl -- highlight overrides ---@field history? boolean --- Notification object ---@class snacks.notifier.Notif: snacks.notifier.Notif.opts ---@field id number|string ---@field msg string ---@field win? snacks.win ---@field icon string ---@field level snacks.notifier.level ---@field timeout number ---@field dirty? boolean ---@field added number timestamp with nano precision ---@field updated number timestamp with nano precision ---@field shown? number timestamp with nano precision ---@field hidden? number timestamp with nano precision ---@field layout? { top?: number, width: number, height: number } --- ### Rendering ---@alias snacks.notifier.render fun(buf: number, notif: snacks.notifier.Notif, ctx: snacks.notifier.ctx) ---@class snacks.notifier.hl ---@field title string ---@field icon string ---@field border string ---@field footer string ---@field msg string ---@class snacks.notifier.ctx ---@field opts snacks.win.Config ---@field notifier snacks.notifier.Class ---@field hl snacks.notifier.hl ---@field ns number --- ### History ---@class snacks.notifier.history ---@field filter? snacks.notifier.level|fun(notif: snacks.notifier.Notif): boolean ---@field sort? string[] # sort fields, default: {"added"} ---@field reverse? boolean ---@type snacks.notifier.history local history_opts = { sort = { "added" }, } Snacks.config.style("notification", { border = "rounded", zindex = 100, ft = "markdown", wo = { winblend = 5, wrap = false, conceallevel = 2, colorcolumn = "", }, bo = { filetype = "snacks_notif" }, }) Snacks.config.style("notification_history", { border = "rounded", zindex = 100, width = 0.6, height = 0.6, minimal = false, title = " Notification History ", title_pos = "center", ft = "markdown", bo = { filetype = "snacks_notif_history", modifiable = false }, wo = { winhighlight = "Normal:SnacksNotifierHistory" }, keys = { q = "close" }, }) ---@class snacks.notifier.Config ---@field enabled? boolean ---@field keep? fun(notif: snacks.notifier.Notif): boolean # global keep function ---@field filter? fun(notif: snacks.notifier.Notif): boolean # filter our unwanted notifications (return false to hide) local defaults = { timeout = 3000, -- default timeout in ms width = { min = 40, max = 0.4 }, height = { min = 1, max = 0.6 }, -- editor margin to keep free. tabline and statusline are taken into account automatically margin = { top = 0, right = 1, bottom = 0 }, padding = true, -- add 1 cell of left/right padding to the notification window sort = { "level", "added" }, -- sort by level and time -- minimum log level to display. TRACE is the lowest -- all notifications are stored in history level = vim.log.levels.TRACE, icons = { error = " ", warn = " ", info = " ", debug = " ", trace = " ", }, keep = function(notif) return vim.fn.getcmdpos() > 0 end, ---@type snacks.notifier.style style = "compact", top_down = true, -- place notifications from top to bottom date_format = "%R", -- time format for notifications -- format for footer when more lines are available -- `%d` is replaced with the number of lines. -- only works for styles with a border ---@type string|boolean more_format = " ↓ %d lines ", refresh = 50, -- refresh at most every 50ms } ---@class snacks.notifier.Class ---@field queue table ---@field history table ---@field sorted? snacks.notifier.Notif[] ---@field opts snacks.notifier.Config local N = {} N.ns = vim.api.nvim_create_namespace("snacks.notifier") ---@param str string local function cap(str) return str:sub(1, 1):upper() .. str:sub(2):lower() end ---@param name string ---@param level? snacks.notifier.level local function hl(name, level) return "SnacksNotifier" .. name .. (level and cap(level) or "") end ---@type table N.styles = { -- style using border title compact = function(buf, notif, ctx) local title = vim.trim(notif.icon .. " " .. (notif.title or "")) if title ~= "" then ctx.opts.title = { { " " .. title .. " ", ctx.hl.title } } ctx.opts.title_pos = "center" end vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(notif.msg, "\n")) end, minimal = function(buf, notif, ctx) ctx.opts.border = "none" local whl = ctx.opts.wo.winhighlight ctx.opts.wo.winhighlight = whl:gsub(ctx.hl.msg, "SnacksNotifierMinimal") vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(notif.msg, "\n")) vim.api.nvim_buf_set_extmark(buf, ctx.ns, 0, 0, { virt_text = { { notif.icon, ctx.hl.icon } }, virt_text_pos = "right_align", }) end, history = function(buf, notif, ctx) local lines = vim.split(notif.msg, "\n", { plain = true }) local prefix = { { os.date(ctx.notifier.opts.date_format, notif.added), hl("HistoryDateTime") }, { notif.icon, ctx.hl.icon }, { notif.level:upper(), ctx.hl.title }, { notif.title, hl("HistoryTitle") }, } prefix = vim.tbl_filter(function(v) return (v[1] or "") ~= "" end, prefix) local prefix_width = 0 for i = 1, #prefix do prefix_width = prefix_width + vim.fn.strdisplaywidth(prefix[i * 2 - 1][1]) + 1 table.insert(prefix, i * 2, { " " }) end local top = vim.api.nvim_buf_line_count(buf) local empty = top == 1 and #vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] == 0 top = empty and 0 or top lines[1] = string.rep(" ", prefix_width) .. (lines[1] or "") vim.api.nvim_buf_set_lines(buf, top, -1, false, lines) vim.api.nvim_buf_set_extmark(buf, ctx.ns, top, 0, { virt_text = prefix, virt_text_pos = "overlay", priority = 10, }) end, -- similar to the default nvim-notify style fancy = function(buf, notif, ctx) vim.api.nvim_buf_set_lines(buf, 0, 1, false, { "", "" }) vim.api.nvim_buf_set_lines(buf, 2, -1, false, vim.split(notif.msg, "\n")) vim.api.nvim_buf_set_extmark(buf, ctx.ns, 0, 0, { virt_text = { { " " }, { notif.icon, ctx.hl.icon }, { " " }, { notif.title or "", ctx.hl.title } }, virt_text_win_col = 0, priority = 10, }) vim.api.nvim_buf_set_extmark(buf, ctx.ns, 0, 0, { virt_text = { { " " }, { os.date(ctx.notifier.opts.date_format, notif.added), ctx.hl.title }, { " " } }, virt_text_pos = "right_align", priority = 10, }) vim.api.nvim_buf_set_extmark(buf, ctx.ns, 1, 0, { virt_text = { { string.rep("━", vim.o.columns - 2), ctx.hl.border } }, virt_text_win_col = 0, priority = 10, }) end, } ---@alias snacks.notifier.level "trace"|"debug"|"info"|"warn"|"error" ---@type table N.levels = { [vim.log.levels.TRACE] = "trace", [vim.log.levels.DEBUG] = "debug", [vim.log.levels.INFO] = "info", [vim.log.levels.WARN] = "warn", [vim.log.levels.ERROR] = "error", } N.level_names = vim.tbl_values(N.levels) ---@type snacks.notifier.level[] ---@param level number|string ---@return snacks.notifier.level local function normlevel(level) return type(level) == "string" and (vim.tbl_contains(N.level_names, level:lower()) and level:lower() or "info") or N.levels[level] or "info" end ---@param level number|string ---@return integer local function numlevel(level) return type(level) == "number" and level or vim.log.levels[normlevel(level):upper()] or 0 end local function ts() if uv.clock_gettime then local ret = assert(uv.clock_gettime("realtime")) return ret.sec + ret.nsec / 1e9 end local sec, usec = uv.gettimeofday() return sec + usec / 1e6 end local _id = 0 local function next_id() _id = _id + 1 return _id end ---@param opts? snacks.notifier.Config ---@return snacks.notifier.Class function N.new(opts) local self = setmetatable({}, { __index = N }) self.opts = Snacks.config.get("notifier", defaults, opts) self.queue = {} self.history = {} self:init() self:start() return self end function N:init() local links = { [hl("History")] = "Normal", [hl("HistoryTitle")] = "Title", [hl("HistoryDateTime")] = "Special", SnacksNotifierMinimal = "NormalFloat", } for _, level in ipairs(N.level_names) do local Level = cap(level) local link = vim.tbl_contains({ "Trace", "Debug" }, Level) and "NonText" or nil links[hl("", level)] = "Normal" links[hl("Icon", level)] = link or ("DiagnosticSign" .. Level) links[hl("Border", level)] = link or ("Diagnostic" .. Level) links[hl("Title", level)] = link or ("Diagnostic" .. Level) links[hl("Footer", level)] = link or ("Diagnostic" .. Level) end Snacks.util.set_hl(links, { default = true }) -- resize handler vim.api.nvim_create_autocmd("VimResized", { group = vim.api.nvim_create_augroup("snacks_notifier", {}), callback = function() for _, notif in pairs(self.queue) do notif.dirty = true end self.sorted = nil end, }) end function N:start() local running = false uv.new_timer():start(self.opts.refresh, self.opts.refresh, function() if running or not next(self.queue) then return end running = true vim.schedule(function() if self.in_search() then running = false return end xpcall(function() self:process() end, function(err) if err:find("E565") then return end local trace = debug.traceback(2) vim.schedule(function() vim.api.nvim_err_writeln( ("Snacks notifier failed. Dropping queue. Error:\n%s\n\nTrace:\n%s"):format(err, trace) ) end) self.queue = {} end) running = false end) end) end function N:process() self:update() self:layout() end function N:is_blocking() local mode = vim.api.nvim_get_mode() for _, m in ipairs({ "ic", "ix", "c", "no", "r%?", "rm" }) do if mode.mode:find(m) == 1 then return true end end return mode.blocking end local health_msg = false ---@param opts snacks.notifier.Notif.opts function N:add(opts) if opts.checkhealth then health_msg = true return end local now = ts() local notif = vim.deepcopy(opts) --[[@as snacks.notifier.Notif]] notif.msg = notif.msg or "" -- NOTE: support nvim-notify style replace ---@diagnostic disable-next-line: undefined-field if not notif.id and notif.replace then ---@diagnostic disable-next-line: undefined-field notif.id = type(notif.replace) == "table" and notif.replace.id or notif.replace end notif.title = (notif.title or ""):gsub("\n", " ") notif.id = notif.id or next_id() notif.level = normlevel(notif.level) notif.icon = notif.icon or self.opts.icons[notif.level] notif.timeout = notif.timeout == false and 0 or notif.timeout notif.timeout = notif.timeout == true and self.opts.timeout or notif.timeout notif.timeout = notif.timeout or self.opts.timeout notif.added = now if opts.id and self.queue[opts.id] then local n = self.queue[opts.id] --[[@as snacks.notifier.Notif]] notif.added = n.added notif.updated = now notif.shown = n.shown and now or nil -- reset shown time notif.win = n.win notif.layout = n.layout notif.dirty = true end self.sorted = nil local want = numlevel(notif.level) >= numlevel(self.opts.level) want = want and (not self.opts.filter or self.opts.filter(notif)) if not want then return notif.id end self.queue[notif.id] = notif if opts.history ~= false then self.history[notif.id] = notif end if self:is_blocking() then pcall(function() self:process() end) end return notif.id end function N:update() local now = ts() --- Cleanup queue for id, notif in pairs(self.queue) do local timeout = notif.timeout or self.opts.timeout local keep = not notif.shown -- not shown yet or timeout == 0 -- no timeout or (notif.win and notif.win:win_valid() and vim.api.nvim_get_current_win() == notif.win.win) -- current window or (notif.win and notif.win:buf_valid() and vim.api.nvim_get_current_buf() == notif.win.buf) -- current buffer or (notif.keep and notif.keep(notif)) -- custom keep or (self.opts.keep and self.opts.keep(notif)) -- global keep or (notif.shown + timeout / 1e3 > now) -- not timed out if not keep then self:hide(id) end end self.sorted = self.sorted or self:sort() end ---@param opts? snacks.notifier.history ---@return snacks.notifier.Notif[] function N:get_history(opts) ---@type snacks.notifier.history opts = vim.tbl_deep_extend("force", {}, history_opts, opts or {}) local notifs = vim.tbl_values(self.history) local filter = opts.filter if type(filter) == "string" or type(filter) == "number" then local level = normlevel(filter) filter = function(n) return n.level == level end end notifs = filter and vim.tbl_filter(filter, notifs) or notifs local ret = self:sort(notifs, opts.sort) if opts.reverse then local rev = {} for i = #ret, 1, -1 do table.insert(rev, ret[i]) end ret = rev end return ret end ---@param opts? snacks.notifier.history function N:show_history(opts) if vim.bo.filetype == "snacks_notif_history" then vim.cmd("close") return end local win = Snacks.win({ style = "notification_history", enter = true, show = false }) local buf = win:open_buf() opts = opts or {} if opts.reverse == nil then opts.reverse = true end for _, notif in ipairs(self:get_history(opts)) do N.styles.history(buf, notif, { opts = win.opts, notifier = self, ns = N.ns, hl = self:hl(notif), }) end return win:show() end ---@param id? number|string function N:hide(id) if not id then for i in pairs(self.queue) do self:hide(i) end return end local notif = self.queue[id] if not notif then return end self.queue[id], self.sorted = nil, nil notif.hidden = ts() if notif.win then notif.win:close() notif.win = nil end end ---@param value number ---@param min number ---@param max number ---@param parent number local function dim(value, min, max, parent) min = math.floor(min < 1 and (parent * min) or min) max = math.floor(max < 1 and (parent * max) or max) return math.min(max, math.max(min, value)) end ---@param style? snacks.notifier.style ---@return snacks.notifier.render function N:get_render(style) style = style or self.opts.style return type(style) == "function" and style or N.styles[style] or N.styles.compact end ---@param notif snacks.notifier.Notif function N:hl(notif) ---@type snacks.notifier.hl return vim.tbl_extend("force", { title = hl("Title", notif.level), icon = hl("Icon", notif.level), border = hl("Border", notif.level), footer = hl("Footer", notif.level), msg = hl("", notif.level), }, notif.hl or {}) end ---@param notif snacks.notifier.Notif function N:render(notif) if type(notif.opts) == "function" then notif.opts(notif) end ---@type snacks.notifier.hl local notif_hl = self:hl(notif) local win = notif.win or Snacks.win({ show = false, style = "notification", enter = false, backdrop = false, ft = notif.ft, noautocmd = true, keys = { q = function() self:hide(notif.id) end, }, }) win.opts.wo.winhighlight = table.concat({ "Normal:" .. notif_hl.msg, "NormalNC:" .. notif_hl.msg, "FloatBorder:" .. notif_hl.border, "FloatTitle:" .. notif_hl.title, "FloatFooter:" .. notif_hl.footer, }, ",") notif.win = win ---@diagnostic disable-next-line: invisible local buf = win:open_buf() vim.api.nvim_buf_clear_namespace(buf, N.ns, 0, -1) local render = self:get_render(notif.style) vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, {}) render(buf, notif, { opts = win.opts, notifier = self, ns = N.ns, hl = notif_hl, }) vim.bo[buf].modifiable = false local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) local pad = self.opts.padding and (win:add_padding() or 2) or 0 local width = win:border_text_width() for _, line in ipairs(lines) do width = math.max(width, vim.fn.strdisplaywidth(line) + pad) end width = dim(width, self.opts.width.min, self.opts.width.max, vim.o.columns) local height = #lines -- calculate wrapped height if win.opts.wo.wrap then height = 0 for _, line in ipairs(lines) do height = height + math.ceil((vim.fn.strdisplaywidth(line) + pad) / width) end end local wanted_height = height height = dim(height, self.opts.height.min, self.opts.height.max, vim.o.lines) if wanted_height > height and win:has_border() and self.opts.more_format and not win.opts.footer then win.opts.footer = self.opts.more_format:format(wanted_height - height) win.opts.footer_pos = "right" end win.opts.width = width win.opts.height = height end ---@param notifs? snacks.notifier.Notif[] ---@param fields? string[] function N:sort(notifs, fields) fields = fields or self.opts.sort notifs = notifs or vim.tbl_values(self.queue) table.sort(notifs, function(a, b) for _, key in ipairs(fields) do local function v(n) if key == "level" then return 10 - numlevel(n[key]) end return n[key] end local av, bv = v(a), v(b) if av ~= bv then return av < bv end end return false end) return notifs end function N:new_layout() ---@class snacks.notifier.layout local layout = {} layout.free = 0 layout.rows = {} ---@type boolean[] ---@param row number ---@param height number ---@param free boolean function layout.mark(row, height, free) for i = row, math.min(row + height - 1, vim.o.lines) do layout.free = layout.free + (free and 1 or -1) layout.rows[i] = free end end ---@param height number ---@param row? number wanted row function layout.find(height, row) local from, to, down = row or 1, vim.o.lines - height, self.opts.top_down for i = down and from or to, down and to or from, down and 1 or -1 do local ret = true for j = i, i + height - 1 do if not layout.rows[j] then ret = false break end end if ret then return i end end end layout.mark(1, vim.o.lines, true) layout.mark(1, self.opts.margin.top + (vim.o.tabline == "" and 0 or 1), false) layout.mark(vim.o.lines - (self.opts.margin.bottom + (vim.o.laststatus == 0 and 0 or 1)) + 1, vim.o.lines, false) return layout end function N:layout() local layout = self:new_layout() local wins_updated = 0 local wins_created = 0 local update = {} ---@type snacks.win[] for _, notif in ipairs(assert(self.sorted)) do if layout.free < (self.opts.height.min + 2) then -- not enough space if notif.win then notif.shown = nil notif.win:hide() end else local prev_layout = notif.layout and { top = notif.layout.top, height = notif.layout.height, width = notif.layout.width } if not notif.win or notif.dirty or not notif.win:buf_valid() or type(notif.opts) == "function" then notif.dirty = true self:render(notif) notif.dirty = false notif.layout = notif.win:size() notif.layout.top = prev_layout and prev_layout.top prev_layout = nil -- always re-render since opts might've changed end notif.layout.top = layout.find(notif.layout.height, notif.layout.top) if notif.layout.top then layout.mark(notif.layout.top, notif.layout.height, false) if not vim.deep_equal(prev_layout, notif.layout) then if notif.win:win_valid() then wins_updated = wins_updated + 1 else wins_created = wins_created + 1 end update[#update + 1] = notif.win notif.win.opts.row = notif.layout.top - 1 notif.win.opts.col = vim.o.columns - notif.layout.width - self.opts.margin.right notif.shown = notif.shown or ts() notif.win:show() end elseif notif.win then notif.shown = nil notif.win:hide() end end end if #update > 0 and not self.in_search() then if vim.api.nvim__redraw then for _, win in ipairs(update) do win:redraw() end else vim.cmd.redraw() end end end function N.in_search() return vim.tbl_contains({ "/", "?" }, vim.fn.getcmdtype()) end ---@param msg string ---@param level? snacks.notifier.level|number ---@param opts? snacks.notifier.Notif.opts function N:notify(msg, level, opts) opts = opts or {} opts.msg = msg opts.level = level return self:add(opts) end -- Global instance local notifier = N.new() ---@param msg string ---@param level? snacks.notifier.level|number ---@param opts? snacks.notifier.Notif.opts function M.notify(msg, level, opts) return notifier:notify(msg, level, opts) end ---@param id? number|string function M.hide(id) return notifier:hide(id) end ---@param opts? snacks.notifier.history function M.get_history(opts) return notifier:get_history(opts) end ---@param opts? snacks.notifier.history function M.show_history(opts) return notifier:show_history(opts) end ---@private function M.health() health_msg = false vim.notify("", nil, { checkhealth = true }) vim.wait(500, function() return health_msg end, 10) if health_msg then Snacks.health.ok("is ready") else Snacks.health.error("is not ready") end end return M