From 9de23b6f90b46f423268c553b1cb483ff5ae02f9 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:39:52 +0200 Subject: [PATCH 01/38] feat: cache rendered object until ttl expires Cache group separator, header and messages to reduce rendering work. The renderer now reuse previous tokens when: - window was not resized (width) - same item count, icon or group name - tokens did not reach ttl expiration (see model.tick) --- lua/fidget/notification.lua | 5 +-- lua/fidget/notification/model.lua | 42 +++++++++++++++++++++ lua/fidget/notification/view.lua | 61 ++++++++++++++++++++++++++++--- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/lua/fidget/notification.lua b/lua/fidget/notification.lua index abb1268..be69a5f 100644 --- a/lua/fidget/notification.lua +++ b/lua/fidget/notification.lua @@ -181,13 +181,13 @@ notification.default_config = { --- ---@param item Item function notification.set_content_key(item) - item.content_key = item.message .. " " .. (item.annote and item.annote or string.char(0)) + item.content_key = item.message .. " " .. (item.annote and item.annote or string.char(0)) end ---@options notification [[ ---@protected --- Notification options -notification.options = { +notification.options = { --- How frequently to update and render notifications --- --- Measured in Hertz (frames per second). @@ -364,7 +364,6 @@ notification.poller = poll.Poller { poll = function(self) notification.model.tick(self:now(), state) - -- TODO: if not modified, don't re-render local lines, width = notification.view.render(self:now(), state.groups) if #lines > 0 then diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index 19fa42e..5236894 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -15,6 +15,47 @@ local M = {} local logger = require("fidget.logger") local poll = require("fidget.poll") +--- Cache used during rendering of notifications. +--- +---@class Cache +---@field group_header table +---@field group_separator CacheSep +---@field render_item table +---@field render_width integer +local cache = {} + +--- Cached rendered lines: reused when `count` matches the current item group count. +--- +---@class CachedItem +---@field it NotificationLine[]|nil +---@field width integer +---@field count integer + +--- Cached rendered lines: reused when `icon` matches the current group header icon. +--- +---@class CachedHdr +---@field hdr NotificationLine[]|nil +---@field width integer +---@field icon string + +--- Cached rendered lines: reused when `group_separator` is set. +--- +---@class CacheSep +---@field sep NotificationLine[]|nil +---@field width integer + +---@return Cache +function M.cache() return cache end + +--- Deletes rendered lines from the cache. +--- +---@param item Item +local function del_cached(item) + if cache.render_item and cache.render_item[item.content_key] then + cache.render_item[item.content_key] = nil + end +end + --- The abstract state of the notifications subsystem. ---@class State ---@field groups Group[] active notification groups @@ -345,6 +386,7 @@ function M.tick(now, state) table.insert(new_items, item) else add_removed(state, now, group, item) + del_cached(item) end end if #group.items > 0 then diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 228ffb1..14b7ef7 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -5,6 +5,9 @@ local M = {} local window = require("fidget.notification.window") +---@type Cache +local cache = require("fidget.notification.model").cache() + --- A list of highlighted tokens. ---@class NotificationLine : NotificationToken[] @@ -203,7 +206,6 @@ function M.render_group_separator() return nil, 0 end return { Line(Token(line, M.options.group_separator_hl)) }, line_width(line) - -- TODO: cache the return value, this never changes end --- Render the header of a group, containing group name and icon. @@ -233,7 +235,7 @@ function M.render_group_header(now, group) if name_tok and icon_tok then ---@cast group_name string ---@cast group_icon string - local sep_tok = Token(M.options.icon_separator) -- TODO: cache this + local sep_tok = Token(M.options.icon_separator) local width = line_width(group_name, group_icon, M.options.icon_separator) if group.config.icon_on_left then return { Line(icon_tok, sep_tok, name_tok) }, width @@ -328,7 +330,7 @@ function M.render_item(item, config, count) item_width = math.max(item_width, line_width(line[1], sep_tok[1], ann_tok[1])) if M.options.align == "annote" then - -- Refund available msg_width with length of annote + sepeparator + -- Refund available msg_width with length of annote + separator msg_width, ann_tok = msg_width + strwidth(sep_tok[1], ann_tok[1]), nil else -- M.options.align == "message" -- Replace annote token with equivalent width of space @@ -421,16 +423,50 @@ function M.render(now, groups) local chunks = {} local max_width = 0 + cache.group_header = cache.group_header or {} + cache.render_item = cache.render_item or {} + + local size = vim.opt.columns:get() + + -- Force rendering when the length of the window change + local resized = cache.render_width and cache.render_width ~= size or false + + if not cache.render_width or resized then + cache.render_width = size + end + for idx, group in ipairs(groups) do if idx ~= 1 then - local sep, sep_width = M.render_group_separator() + local sep, sep_width + if cache.group_separator and not resized then + sep = cache.group_separator.sep + sep_width = cache.group_separator.width + else + sep, sep_width = M.render_group_separator() + cache.group_separator = { sep = sep, width = sep_width } + end if sep then table.insert(chunks, sep) max_width = math.max(max_width, sep_width) end end - local hdr, hdr_width = M.render_group_header(now, group) + local icon = group.config.icon + if type(icon) == "function" then + icon = group.config.icon(now, group.items) + end + local hdr, hdr_width + if cache.group_header + and not resized + and cache.group_header[group.config.name] + and cache.group_header[group.config.name].icon == icon + then + hdr = cache.group_header[group.config.name].hdr + hdr_width = cache.group_header[group.config.name].width + else + hdr, hdr_width = M.render_group_header(now, group) + cache.group_header[group.config.name] = { hdr = hdr, width = hdr_width, icon = icon } + end if hdr then table.insert(chunks, hdr) max_width = math.max(max_width, hdr_width) @@ -443,7 +479,20 @@ function M.render(now, groups) break end - local it, it_width = M.render_item(item, group.config, counts[item.content_key or item]) + local key = item.content_key or item + + local it, it_width + if cache.render_item[key] + and not resized + and counts[key] == cache.render_item[key].count + and cache.render_width == size + then + it = cache.render_item[key].it + it_width = cache.render_item[key].width + else + it, it_width = M.render_item(item, group.config, counts[key]) + cache.render_item[key] = { it = it, width = it_width, count = counts[key] } + end if it then table.insert(chunks, it) max_width = math.max(max_width, it_width) From 17389e78556569a63be48d9b052d82d8825870d3 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sun, 11 Jan 2026 04:42:04 +0200 Subject: [PATCH 02/38] feat: refactor notification view renderer Lines are now rendered using array of words composed of only non-space characters. Spacing is added on the fly by calculating the start and the end position of each words. Fidget now automatically reflows lines that exceed max_width via word-break. Annotes indentation is set by default to "annote" (see options.align) This is the first step toward introducing tree-sitter highlight inside the renderer. --- lua/fidget/notification/view.lua | 237 +++++++++++++++-------------- lua/fidget/notification/window.lua | 30 +++- 2 files changed, 146 insertions(+), 121 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 14b7ef7..006a0ff 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -175,6 +175,46 @@ local function line_width(...) return w == 0 and w or w + line_margin() end +--- Tokenize a string into a list of tokens. +--- +--- A token is a contiguous sequence of alphanumeric characters or an individual non-space character. +--- Ignores consecutives whitespace. +--- +--- scol ecol word +---@alias Token table +--- +---@param source string +---@return Token[] +local function Tokenize(source) + local pos = 1 + local res = {} + + while pos <= #source do + local scol, ecol, w = source:find("(%w+)", pos) + + if not scol then + for i = pos, #source do + local c = source:sub(i, i) + if c:match("%S") then + table.insert(res, { i, i, c }) + end + end + break + end + for i = pos, scol - 1 do + local c = source:sub(i, i) + if c:match("%S") then + table.insert(res, { i, i, c }) + end + end + table.insert(res, { scol, ecol, w }) + pos = ecol + 1 + end + return res +end + +--- Pack an arbitrary text and its highlight inside a notification token. +--- ---@param text string the text in this token ---@param ... string highlights to apply to text ---@return NotificationToken @@ -185,6 +225,8 @@ local function Token(text, ...) return { text, { window.no_blend_hl, ... } } end +--- Pack a notification token inside margin and returns a notification line. +--- ---@param ... NotificationToken ---@return NotificationLine local function Line(...) @@ -198,6 +240,34 @@ local function Line(...) return line end +--- Insert an annote or indent associated content line. +--- +---@param line table +---@param width integer +---@param annote NotificationToken +---@param first boolean +---@return table line +---@return integer width +local function Annote(line, width, annote, sep, first) + if not annote then + return line, width + end + if first then + annote[1] = sep .. annote[1] + table.insert(line, annote) + + width = width + line_width(annote[1]) + else + -- Indent messages longer than a single line (see notification.view.align) + if M.options.align == "message" then + table.insert(line, Token(string.rep(sep, #annote[1]))) + + width = width + #annote[1] + end + end + return line, width +end + ---@return NotificationLine[]|nil lines ---@return integer width function M.render_group_separator() @@ -289,125 +359,66 @@ function M.render_item(item, config, count) return nil, 0 end - local lines, item_width = {}, 0 - - local ann_tok = item.annote and Token(item.annote, item.style) - local sep_tok = Token(config.annote_separator or " ") - - -- How much space is available to message lines - local msg_width = window.max_width() - - line_margin() -- adjusted for line margin - - 1 -- adjusted for ext_mark margin - - local presplit_char, postsplit_char = nil, nil - if M.options.reflow == "hyphenate" then - presplit_char = "-" - elseif M.options.reflow == "ellipsis" then - presplit_char = "…" - postsplit_char = "…" + local hl = {} + if not is_multigrid_ui then + table.insert(hl, window.no_blend_hl) end - if ann_tok and msg_width ~= math.huge and M.options.reflow then - -- If we need annote, adjust remaining available width for message line(s) - local ann_width = strwidth(sep_tok[1], ann_tok[1]) - if ann_width > msg_width then - -- No room for annote + item line. Put annote on line of its own. - table.insert(lines, Line(ann_tok)) - item_width = line_width(ann_tok[1]) - -- Pretend there was no annote to begin with. - ann_tok = nil - else - -- Reduce available space for message line(s); that will get printed next - -- to annote and sep in first iteration of loop below. - msg_width = msg_width - ann_width - end + if M.options.normal_hl ~= "Normal" and M.options.normal_hl ~= "" then + table.insert(hl, M.options.normal_hl) + else + table.insert(hl, "Normal") -- default end - local function insert(line) - if ann_tok then - -- Need to emite annote token in this line - table.insert(lines, Line(line, sep_tok, ann_tok)) - item_width = math.max(item_width, line_width(line[1], sep_tok[1], ann_tok[1])) - - if M.options.align == "annote" then - -- Refund available msg_width with length of annote + separator - msg_width, ann_tok = msg_width + strwidth(sep_tok[1], ann_tok[1]), nil - else -- M.options.align == "message" - -- Replace annote token with equivalent width of space - ann_tok = Token(string.rep(" ", strwidth(ann_tok[1]))) - end - else - table.insert(lines, Line(line)) - item_width = math.max(item_width, line_width(line[1])) - end - end + local width = 0 + local max_width = vim.opt.columns:get() - line_margin() - 4 - for whole_line in vim.gsplit(msg, "\n", { plain = true, trimempty = true }) do - local lwidth = strwidth(whole_line) - if msg_width >= lwidth then - -- The entire line fits into the available space; insert it as is. - -- Note that we do not trim it either. - insert(Token(whole_line)) - elseif not M.options.reflow then - -- If the message is wider than available space but we are explicitly - -- asked not to reflow, then just truncate it. - insert(Token(vim.fn.strcharpart(whole_line, 0, msg_width, true))) - else - local split_begin, postsplit = 0, nil - whole_line = vim.fn.trim(whole_line) - while lwidth > 0 do - local split_len, presplit = msg_width, nil - if postsplit then - split_len = split_len - 1 - end + local tokens = {} + local annote = item.annote and Token(item.annote, item.style) + local sep = config.annote_separator or " " - if lwidth > split_len - and not whitespace(whole_line, split_begin + split_len) - and not whitespace(whole_line, split_begin + split_len - 1) - then - if whitespace(whole_line, split_begin + split_len - 2) then - -- Avoid "split w/ord" - split_len = split_len - 1 - elseif presplit_char then - if whitespace(whole_line, split_begin + split_len - 3) then - -- Avoid "split w-/ord" - split_len = split_len - 2 - else - split_len = split_len - 1 - presplit = presplit_char - end - end - end + for s in vim.gsplit(msg, "\n", { plain = true, trimempty = true }) do + local line = {} + local line_ptr = 0 + local prev_end = 0 + local next_start = 0 - local line = vim.fn.strcharpart(whole_line, split_begin, split_len, true) - line = vim.fn.trim(line) - if postsplit then - line = postsplit .. line - end - if presplit then - line = line .. presplit - postsplit = postsplit_char - else - postsplit = nil + for _, token in ipairs(Tokenize(s)) do + if not token then + break + end + local spacing = token[1] - prev_end + + -- Check if the line would overflow notification window if added as it is + if line_ptr + #token[3] + spacing >= max_width - (annote and line_width(annote[1]) or 0) then + if annote then + line, width = Annote(line, width, annote, sep, #tokens == 0) end - insert(Token(line)) - split_begin = split_begin + split_len - lwidth = lwidth - split_len + table.insert(tokens, Line(unpack(line))) -- push to newline + line = {} + line_ptr = 0 + next_start = token[1] end + table.insert(line, { + scol = (token[1] == 1 and 0 or token[1]) - next_start, + ecol = token[2] - next_start + 1, + text = token[3] + }) + prev_end = token[2] + 1 + line_ptr = line_ptr + #token[3] + spacing + + width = math.max(width, line_ptr + line_margin()) end - end - - if #lines == 0 then - if ann_tok then - -- The message is an empty string, but there's an annotation to render. - return { Line(ann_tok) }, line_width(item.annote) - else - -- Don't render empty messages - return nil, 0 + if annote then + line, width = Annote(line, width, annote, sep, #tokens == 0) end - else - return lines, item_width + table.insert(tokens, Line(unpack(line))) + end + -- The message is an empty string but there's an annotation to render + if #tokens == 0 and annote then + tokens = { Line(annote) } end + return tokens, width end --- Render notifications into lines and highlights. @@ -457,9 +468,9 @@ function M.render(now, groups) end local hdr, hdr_width if cache.group_header - and not resized - and cache.group_header[group.config.name] - and cache.group_header[group.config.name].icon == icon + and not resized + and cache.group_header[group.config.name] + and cache.group_header[group.config.name].icon == icon then hdr = cache.group_header[group.config.name].hdr hdr_width = cache.group_header[group.config.name].width @@ -483,9 +494,9 @@ function M.render(now, groups) local it, it_width if cache.render_item[key] - and not resized - and counts[key] == cache.render_item[key].count - and cache.render_width == size + and not resized + and counts[key] == cache.render_item[key].count + and cache.render_width == size then it = cache.render_item[key].it it_width = cache.render_item[key].width diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index 5fab6a3..cc6f992 100644 --- a/lua/fidget/notification/window.lua +++ b/lua/fidget/notification/window.lua @@ -583,28 +583,42 @@ function M.set_lines(lines, width) local empty_lines = vim.tbl_map(function() return "" end, lines) vim.api.nvim_buf_set_lines(buffer_id, 0, -1, false, empty_lines) - for iline, line in ipairs(lines) do + for index, line in ipairs(lines) do + local prev_ecol = 0 + local chunk = {} + + for _, t in ipairs(line) do + if t.text then + if prev_ecol < t.scol then + table.insert(chunk, { string.rep(" ", t.scol - prev_ecol) }) + end + table.insert(chunk, { t.text, t.hl }) + prev_ecol = t.ecol + else + table.insert(chunk, t) -- backward compatibility + end + end + if vim.fn.has("nvim-0.11.0") == 1 then - vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, iline - 1, 0, { - virt_text = line, + vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, index - 1, 0, { + virt_text = chunk, virt_text_pos = "eol_right_align", }) else -- pre-0.11.0: eol_right_align was only introduced in 0.11.0; -- without it we need to compute and add the padding ourselves local len, padded = 0, { {} } - for _, tok in ipairs(line) do - len = len + vim.fn.strwidth(tok[1]) + - vim.fn.count(tok[1], "\t") * math.max(0, M.options.tabstop - 1) + for _, tok in ipairs(chunk) do + len = len + vim.fn.strwidth(tok[1]) + vim.fn.count(tok[1], "\t") * math.max(0, M.options.tabstop - 1) table.insert(padded, tok) end local pad_width = math.max(0, width - len) if pad_width > 0 then padded[1] = { string.rep(" ", pad_width), {} } else - padded = line + padded = chunk end - vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, iline - 1, 0, { + vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, index - 1, 0, { virt_text = padded, virt_text_pos = "eol", }) From 85137895630511ab5a526dc765c568af668a9152 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 17 Jan 2026 03:53:03 +0200 Subject: [PATCH 03/38] feat: add treesitter highlight to view renderer Related to #159 Implements tree-sitter highlight to rendered tokens. --- lua/fidget/notification/view.lua | 149 ++++++++++++++++++++++++++++--- 1 file changed, 137 insertions(+), 12 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 006a0ff..775093b 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -4,6 +4,7 @@ local M = {} local window = require("fidget.notification.window") +local logger = require("fidget.logger") ---@type Cache local cache = require("fidget.notification.model").cache() @@ -130,6 +131,14 @@ function M.check_multigrid_ui() return false end +---@return string +local function normal_hl() + if window.options.normal_hl ~= "Normal" and window.options.normal_hl ~= "" then + return window.options.normal_hl + end + return "Normal" -- default +end + --- Whether nr is a codepoint representing whitespace. --- ---@param s string @@ -268,6 +277,89 @@ local function Annote(line, width, annote, sep, first) return line, width end +--- Returns the Treesitter highlight groups for a given source and language. +--- +---@param source string +---@param lang string +---@return table|nil hls +local function Highlight(source, lang) + local ok, parser = pcall(function() + return vim.treesitter.get_string_parser(source, lang) + end) + if not ok then + logger.warn(parser) + return + end + local tree = parser:parse()[1] + local query = vim.treesitter.query.get(lang, "highlights") + if not query then + return -- query file not found + end + + local hls = {} -- holds captured hl + local line = {} + local prev_line = 0 + local prev_text, prev_range + + for id, node in query:iter_captures(tree:root(), source) do + local text = vim.treesitter.get_node_text(node, source) + if not text then + goto continue + end + local name = query.captures[id] + if name == "spell" or name == "nospell" then + goto continue -- ignores spellcheck + end + + -- Finds hl groups id if exists + local hl = vim.fn.hlID(name) + if hl == 0 then + hl = vim.fn.hlID("@" .. name) + if hl == 0 then + hl = vim.fn.hlID(normal_hl()) -- fallback + end + end + + local srow, scol, _, ecol = node:range() + if prev_line ~= srow then + table.insert(hls, line) -- push to a new line + line = {} + prev_line = srow + end + if prev_text ~= text then + prev_text = text + prev_range = srow + else + if srow == prev_range then + line[#line].hl = hl -- latest node takes priority + end + end + -- Uses the same item renderer struct + for _, token in ipairs(Tokenize(text)) do + local t = { + srow = srow, + scol = scol, + ecol = ecol, + text = token[3], + hl = hl + } + if not vim.tbl_contains(line, + function(w) + return vim.deep_equal(w, t) + end, { predicate = true }) + then + table.insert(line, t) + end + end + ::continue:: + end + if #line > 0 then + table.insert(hls, line) + return hls + end + return nil +end + ---@return NotificationLine[]|nil lines ---@return integer width function M.render_group_separator() @@ -363,12 +455,7 @@ function M.render_item(item, config, count) if not is_multigrid_ui then table.insert(hl, window.no_blend_hl) end - - if M.options.normal_hl ~= "Normal" and M.options.normal_hl ~= "" then - table.insert(hl, M.options.normal_hl) - else - table.insert(hl, "Normal") -- default - end + table.insert(hl, normal_hl()) local width = 0 local max_width = vim.opt.columns:get() - line_margin() - 4 @@ -377,11 +464,20 @@ function M.render_item(item, config, count) local annote = item.annote and Token(item.annote, item.style) local sep = config.annote_separator or " " + -- TODO: + -- add an M.options to toggle this + -- add a default_highlight = "markdown_inline" or smthing + local hls = Highlight(msg, "markdown") + if not hls then + logger.warn("nothing to highlights in this message!") + end + for s in vim.gsplit(msg, "\n", { plain = true, trimempty = true }) do local line = {} local line_ptr = 0 local prev_end = 0 local next_start = 0 + local extra_line = 0 for _, token in ipairs(Tokenize(s)) do if not token then @@ -395,18 +491,47 @@ function M.render_item(item, config, count) line, width = Annote(line, width, annote, sep, #tokens == 0) end table.insert(tokens, Line(unpack(line))) -- push to newline - line = {} - line_ptr = 0 next_start = token[1] + extra_line = extra_line + 1 + line_ptr = 0 + line = {} end - table.insert(line, { + + local word = { scol = (token[1] == 1 and 0 or token[1]) - next_start, ecol = token[2] - next_start + 1, - text = token[3] - }) + text = token[3], + hl = hl + } + table.insert(line, word) + + -- Adds treesitter highlights + if hls then + for _, tsline in ipairs(hls) do + for _, ts in ipairs(tsline) do + if ts.text == word.text and ts.srow + extra_line == #tokens then + -- paint the whole line + if ts.scol == 0 and ts.ecol == 0 + or + ts.scol - next_start < word.ecol and ts.ecol - next_start + 1 > word.scol + then + -- Removes concealed token + -- NOTE: should we open this to M.options? mb users want to see concealed? + if ts.hl == vim.fn.hlID("conceal") then + line_ptr = line_ptr - #word.text + word.text = "" + end + word.hl = vim.tbl_map(function(value) + if value ~= window.no_blend_hl then value = ts.hl end + return value + end, word.hl) + end + end + end + end + end prev_end = token[2] + 1 line_ptr = line_ptr + #token[3] + spacing - width = math.max(width, line_ptr + line_margin()) end if annote then From 9af680d23091a3b3249bfd7b113ca5c3c91dc6cf Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 17 Jan 2026 03:56:07 +0200 Subject: [PATCH 04/38] feat: add renderer highlight config options Default highlight is set to "markdown_inline". Toggle default notification highlight: - notification.view.highlight = string|false Hide concealed markdown tags: - notification.view.hide_conceal = boolean --- lua/fidget/notification/view.lua | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 775093b..c7acbbb 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -27,6 +27,16 @@ M.options = { ---@type boolean stack_upwards = true, + --- Automatically highlight notification using tree-sitter + --- + ---@type string|false + highlight = "markdown_inline", + + --- Hide markdown tags with the "conceal" highlight name + --- + ---@type boolean + hide_conceal = true, + --- Indent messages longer than a single line --- --- Example: ~ @@ -464,12 +474,9 @@ function M.render_item(item, config, count) local annote = item.annote and Token(item.annote, item.style) local sep = config.annote_separator or " " - -- TODO: - -- add an M.options to toggle this - -- add a default_highlight = "markdown_inline" or smthing - local hls = Highlight(msg, "markdown") - if not hls then - logger.warn("nothing to highlights in this message!") + local hls + if M.options.highlight and M.options.highlight ~= "" then + hls = Highlight(msg, M.options.highlight) end for s in vim.gsplit(msg, "\n", { plain = true, trimempty = true }) do @@ -516,10 +523,11 @@ function M.render_item(item, config, count) ts.scol - next_start < word.ecol and ts.ecol - next_start + 1 > word.scol then -- Removes concealed token - -- NOTE: should we open this to M.options? mb users want to see concealed? - if ts.hl == vim.fn.hlID("conceal") then - line_ptr = line_ptr - #word.text - word.text = "" + if M.options.hide_conceal then + if ts.hl == vim.fn.hlID("conceal") then + line_ptr = line_ptr - #word.text + word.text = "" + end end word.hl = vim.tbl_map(function(value) if value ~= window.no_blend_hl then value = ts.hl end From 937cae439e1900b2d963c6eb25837547cc11923c Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 17 Jan 2026 05:06:06 +0200 Subject: [PATCH 05/38] feat: merge markdown with markdown_inline The highlight function can now reuse previously generated hl tokens. --- lua/fidget/notification/view.lua | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index c7acbbb..9b3ab19 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -289,10 +289,11 @@ end --- Returns the Treesitter highlight groups for a given source and language. --- ----@param source string ----@param lang string +---@param source string +---@param lang string +---@param prev_hls table|nil ---@return table|nil hls -local function Highlight(source, lang) +local function Highlight(source, lang, prev_hls) local ok, parser = pcall(function() return vim.treesitter.get_string_parser(source, lang) end) @@ -307,6 +308,9 @@ local function Highlight(source, lang) end local hls = {} -- holds captured hl + if prev_hls then + hls = prev_hls + end local line = {} local prev_line = 0 local prev_text, prev_range @@ -477,6 +481,12 @@ function M.render_item(item, config, count) local hls if M.options.highlight and M.options.highlight ~= "" then hls = Highlight(msg, M.options.highlight) + if hls then + -- Also use inline for markdown + if M.options.highlight == "markdown" then + hls = Highlight(msg, "markdown_inline", hls) + end + end end for s in vim.gsplit(msg, "\n", { plain = true, trimempty = true }) do From 2d5ae619b17af22e9bc3cc67b662942b0215f60a Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 17 Jan 2026 05:34:53 +0200 Subject: [PATCH 06/38] fix: clean up dead code, lsp warning etc --- lua/fidget/notification.lua | 10 ++++---- lua/fidget/notification/model.lua | 2 +- lua/fidget/notification/view.lua | 40 ------------------------------- 3 files changed, 6 insertions(+), 46 deletions(-) diff --git a/lua/fidget/notification.lua b/lua/fidget/notification.lua index be69a5f..0b94f6c 100644 --- a/lua/fidget/notification.lua +++ b/lua/fidget/notification.lua @@ -86,11 +86,11 @@ local logger = require("fidget.logger") --- A notification element in the notifications history. --- ---@class HistoryItem : Item ----@field removed boolean Whether this item is deleted ----@field group_key Key Key of the group this item belongs to ----@field group_name string|nil Title of the group this item belongs to ----@field group_icon string|nil Icon of the group this item belongs to ----@field last_updated number What time this item was last updated, in seconds since Jan 1, 1970 +---@field removed boolean Whether this item is deleted +---@field group_key Key Key of the group this item belongs to +---@field group_name string|false|nil Title of the group this item belongs to +---@field group_icon string|false|nil Icon of the group this item belongs to +---@field last_updated number What time this item was last updated, in seconds since Jan 1, 1970 --- Filter options when querying for notifications history. --- diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index 5236894..1c96c91 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -73,7 +73,7 @@ end ---@class HistoryExtra ---@field removed boolean ---@field group_key Key ----@field group_name string|nil +---@field group_name string|false|nil ---@field group_icon string|nil --- Get the notification group indexed by group_key; create one if none exists. diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 9b3ab19..895417c 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -1,6 +1,5 @@ --- Helper methods used to render notification model elements into views. --- ---- TODO: partial/in-place rendering, to avoid building new strings. local M = {} local window = require("fidget.notification.window") @@ -53,32 +52,6 @@ M.options = { ---@type "message"|"annote" align = "message", - --- Reflow (wrap) messages wider than notification window - --- - --- The various options determine how wrapping is handled mid-word. - --- - --- Example: ~ - ---> - --- "hard" is reflo INFO - --- wed like this - --- - --- "hyphenate" is ref- INFO - --- lowed like this - --- - --- "ellipsis" is refl… INFO - --- …owed like this - ---< - --- - --- If this option is set to false, long lines will simply be truncated. - --- - --- This option has no effect if |fidget.option.notification.window.max_width| - --- is `0` (i.e., infinite). - --- - --- Annotes longer than this width on their own will not be wrapped. - --- - ---@type "hard"|"hyphenate"|"ellipsis"|false - reflow = false, - --- Separator between group name and icon --- --- Must not contain any newlines. Set to `""` to remove the gap between names @@ -149,19 +122,6 @@ local function normal_hl() return "Normal" -- default end ---- Whether nr is a codepoint representing whitespace. ---- ----@param s string ----@param index integer ----@return boolean -local function whitespace(s, index) - -- Same heuristic as vim.fn.trim(): <= 32 includes all ASCII whitespace - -- (as well as other control chars, which we don't care about). - -- Note that 160 is the unicode no-break space but we don't want to break on - -- that anyway. - return vim.fn.strgetchar(s, index) <= 32 -end - --- The displayed width of some strings. --- --- A simple wrapper around vim.fn.strwidth(), accounting for tab characters From 75940b6c506841b5a1c957c910b8f55fbaaa7905 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Mon, 19 Jan 2026 01:02:01 +0200 Subject: [PATCH 07/38] fix: prevent extra_line to reset on each iteration --- lua/fidget/notification/view.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 895417c..4b32a95 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -449,12 +449,14 @@ function M.render_item(item, config, count) end end + -- Safeguard against lines overflow + local extra_line = 0 + for s in vim.gsplit(msg, "\n", { plain = true, trimempty = true }) do local line = {} local line_ptr = 0 local prev_end = 0 local next_start = 0 - local extra_line = 0 for _, token in ipairs(Tokenize(s)) do if not token then From c4e6363f31ddf7b0e3f57ddaa7c9fcbe5dab8c24 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Mon, 19 Jan 2026 02:30:17 +0200 Subject: [PATCH 08/38] fix: use window max_width option before fallback --- lua/fidget/notification/view.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 4b32a95..cfc0731 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -432,7 +432,10 @@ function M.render_item(item, config, count) table.insert(hl, normal_hl()) local width = 0 - local max_width = vim.opt.columns:get() - line_margin() - 4 + local max_width = window.options.max_width + if max_width <= 0 then + max_width = vim.opt.columns:get() - line_margin() - 4 + end local tokens = {} local annote = item.annote and Token(item.annote, item.style) From 59f59e043cd2fa1435c24d800e29661189c9913e Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Mon, 19 Jan 2026 03:32:57 +0200 Subject: [PATCH 09/38] feat: add renderer test file --- tests/notify.lua | 274 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 tests/notify.lua diff --git a/tests/notify.lua b/tests/notify.lua new file mode 100644 index 0000000..e6e6802 --- /dev/null +++ b/tests/notify.lua @@ -0,0 +1,274 @@ +local logger = require("fidget.logger") +local notif = require("fidget.notification") + +local str = { + line_msg = "A notification message!", + line_msg_log = "A notification message with log level!", + line_msg_annote = "A notification message with annote!", + line_msg_markdown = "A **notification** message `with` ~log~ markdown!", + long_line_msg = "This is a very very long line that stretches beyond the usual " .. + "notification limit and is meant to test how the UI handles overflow.", + long_line_markdown = "This is a very **very** long line that stretches `beyond` the usual " .. + "notification ~limit~ and is meant to [test] how the _UI_ handles overflow.", + multiline_msg = "align message style\nlooks like this\nwhen reflowed", + block_lua = [[ +-- This is a `lua` function! + +function abc() + print("Hello, world!") + local a = 2 * 6 -- inline comment + return a +end +]], + block_go = [[ +package main + +import "fmt" + +func main() { + // this is a comment + fmt.Println("Hello, world!") +} +]], + block_md = [[ +# A cool title + +**Features in bold** +- Lightweight and fast +- Easy to integrate with `this feature` +- Open sourced + +> Some notes here, and in a "quote". + +Sample of `code` here: +```lua +function foo() + print("some text here") +end +``` +]] +} + +-- a notification window with a message +local function line_msg() + notif.notify(str.line_msg) +end + +-- a notification window with INFO annote +local function line_msg_log() + notif.notify(str.line_msg_log, 2) +end + +-- a notification window with foo annote +local function line_msg_annote() + notif.notify(str.line_msg_annote, nil, { annote = "foo" }) +end + +-- a notification window with markdown highlight, tags removed +local function line_msg_markdown() + notif.notify(str.line_msg_markdown) +end + +-- a notification window with markdown highlight, tags visible +local function line_msg_markdown_show_conceal() + notif.view.options.hide_conceal = false + notif.notify(str.line_msg_markdown) +end + +-- a notification window with markdown text, no highlight +local function line_msg_markdown_highlight_off() + notif.view.options.highlight = false + notif.notify(str.line_msg_markdown) +end + +-- a notification window with markdown highlight, tags removed +-- blending highlight follows global colorscheme change +-- NOTE: reproduces #298, this test will fail until fixed +local function line_msg_colorscheme() + vim.cmd("colorscheme blue") + notif.notify(str.line_msg_markdown) +end + +-- a notification window, single line overflow split in multi lines +local function long_line_msg() + notif.notify(str.long_line_msg) +end + +-- a notification window, single line overflow split in multi lines +-- respect max_width with no overflow +local function long_line_msg_resized() + notif.notify(str.long_line_msg) + notif.window.options.max_width = 40 +end + +-- a notification window, single line overflow split in multi with INFO annote +-- aligned by annote (respecting line_margin) +local function long_line_msg_annote() + notif.view.options.align = "annote" + notif.notify(str.long_line_msg, 2) +end + +-- a notification window, single line overflow split in multi lines with INFO annote +-- aligned by message (respecting line_margin) +local function long_line_msg_align() + notif.view.options.align = "message" + notif.notify(str.long_line_msg, 2) +end + +-- a notification window, single line overflow split in multi lines +-- each lines are highlighted using markdown_inline even when resized +local function long_line_msg_markdown() + notif.notify(str.long_line_markdown) +end + +-- a notification window, multi lines +local function multi_line_msg() + notif.notify(str.multiline_msg) +end + +-- a notification window, multi lines with foo annote and aligned by annote +local function multi_line_msg_annote() + notif.view.options.align = "annote" + notif.notify(string.gsub(str.multiline_msg, "message", "annote"), nil, { annote = "foo" }) +end + +-- a notification window, multi lines with foo annote and aligned by message +local function multi_line_msg_align() + notif.view.options.align = "message" + notif.notify(str.multiline_msg, nil, { annote = "foo" }) +end + +-- a notification window, multi lines with one line overflowing the window split in multi lines +-- the message content is set with an INFO annote +local function multi_line_msg_overflow() + notif.notify(str.multiline_msg .. "\n" .. str.long_line_msg, 2) +end + +-- a notification window with an highlighted [[block of lua code]] +-- colors should be the same as filetype=lua +local function block_msg_lua() + notif.view.options.highlight = "lua" + notif.notify(str.block_lua) +end + +-- a notification window with an highlighted [[block of go code]] +-- colors should be the same as filetype=go +local function block_msg_go() + notif.view.options.highlight = "go" + notif.notify(str.block_go) +end + +-- a notification window with an highlighted [[block of markdown text]] +-- colors should be the same as filetype=markdown +-- +-- NOTE: mixing highlight with ```lang\n text``` is not yet supported +local function block_msg_markdown() + notif.view.options.highlight = "markdown" + notif.notify(str.block_md) +end + +-- a notification window, empty of message with a title +local function empty_msg() + notif.notify("") +end + +-- a notification window, empty of message with <- empty msg annote +local function empty_msg_annote() + notif.notify("", nil, { annote = "<- empty msg" }) +end + +-- a notification window with empty annote -> message with empty annote +local function empty_annote() + notif.notify("empty annote ->", nil, { annote = "" }) +end + +-- the notification window is cleared and closed +local function clear() + notif.clear() + -- clean cache etc if needed here +end + +--- +local M = { + --- name test time offset + ---@type table + test = { + { "line_msg", line_msg, 0 }, + { "line_msg_log", line_msg_log, 0 }, + { "line_msg_annote", line_msg_annote, 0 }, + { "line_msg_markdown", line_msg_markdown, 0 }, + { "line_msg_markdown_show_conceal", line_msg_markdown_show_conceal, 0 }, + { "line_msg_markdown_highlight_off", line_msg_markdown_highlight_off, 0 }, + { "line_msg_colorscheme", line_msg_colorscheme, 0 }, + { "clear", clear, 4 }, + { "long_line_msg", long_line_msg, 0 }, + { "long_line_msg_resized", long_line_msg_resized, 0 }, + { "long_line_msg_annote", long_line_msg_annote, 1 }, + { "long_line_msg_align", long_line_msg_align, 0 }, + { "long_line_msg_markdown", long_line_msg_markdown, 0 }, + { "clear", clear, 4 }, + { "multiline_msg", multi_line_msg, 0 }, + { "multi_line_msg_annote", multi_line_msg_annote, 0 }, + { "multi_line_msg_align", multi_line_msg_align, 0 }, + { "multi_line_msg_overflow", multi_line_msg_overflow, 0 }, + { "clear", clear, 4 }, + { "block_msg_lua", block_msg_lua, 0 }, + { "clear", clear, 1 }, + { "block_msg_go", block_msg_go, 0 }, + { "clear", clear, 1 }, + { "block_msg_markdown", block_msg_markdown, 0 }, + { "clear", clear, 1 }, + { "empty_msg", empty_msg, 0 }, + { "empty_msg_annote", empty_msg_annote, 0 }, + { "empty_annote", empty_annote, 0 }, + }, +} + +function M.run() + logger.options.level = vim.log.levels.DEBUG + logger.debug("-- test --") + + -- notif.view.options.line_margin = 8 + -- notif.default_config.ttl = 500 + + M.config = { + colorscheme = vim.g.colors_name, + window = vim.deepcopy(notif.window.options), + view = vim.deepcopy(notif.view.options), + } + local offset = 0 + + for time, test in ipairs(M.test) do + if #test == 2 then + test[3] = 0 + end + local t = vim.uv.new_timer() + if t then + offset = offset + test[3] + t:start((time + offset) * 1000, 0, vim.schedule_wrap( + function() + t:stop() + local ok, err = pcall( + function() + -- start test with a clean config + if M.config then + if M.config.colorscheme ~= vim.g.colors_name then + vim.cmd("colorscheme " .. M.config.colorscheme) + end + for k, v in pairs(M.config.window) do notif.window.options[k] = v end + for k, v in pairs(M.config.view) do notif.view.options[k] = v end + end + logger.debug("run " .. test[1]) + test[2]() + end) + if not ok then + logger.debug("=> " .. err) + end + t:close() + end + )) + end + end +end + +return M From 2a6266ff44afc340c0800c9d122bea9451102d09 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Thu, 22 Jan 2026 04:28:26 +0200 Subject: [PATCH 10/38] feat: add unicode characters support --- lua/fidget/notification/view.lua | 70 ++++++++++++++++++-------------- tests/notify.lua | 39 ++++++++++++++++-- 2 files changed, 75 insertions(+), 34 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index cfc0731..4cc6997 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -156,38 +156,42 @@ end --- Tokenize a string into a list of tokens. --- ---- A token is a contiguous sequence of alphanumeric characters or an individual non-space character. +--- A token is a contiguous sequence of characters or an individual non-space character. --- Ignores consecutives whitespace. ---- ---- scol ecol word ----@alias Token table +--- scol ecol word +---@alias Token { [1]: integer, [2]: integer, [3]: string } --- ---@param source string ---@return Token[] local function Tokenize(source) - local pos = 1 + local pos = 0 local res = {} + local len = vim.fn.strchars(source) + + while pos < len do + ---@type string + local char = vim.fn.strcharpart(source, pos, 1) - while pos <= #source do - local scol, ecol, w = source:find("(%w+)", pos) + if char:match("%w") then + local ptr = pos + local word = { char } - if not scol then - for i = pos, #source do - local c = source:sub(i, i) - if c:match("%S") then - table.insert(res, { i, i, c }) + while ptr + 1 < len do + local c = vim.fn.strcharpart(source, ptr + 1, 1) + if not c:match("%w") then + break end + table.insert(word, c) + ptr = ptr + 1 end - break - end - for i = pos, scol - 1 do - local c = source:sub(i, i) - if c:match("%S") then - table.insert(res, { i, i, c }) + table.insert(res, { pos, ptr, table.concat(word) }) + pos = ptr + 1 + else + if not char:match("%s") then + table.insert(res, { pos, pos, char }) end + pos = pos + 1 end - table.insert(res, { scol, ecol, w }) - pos = ecol + 1 end return res end @@ -239,9 +243,10 @@ local function Annote(line, width, annote, sep, first) else -- Indent messages longer than a single line (see notification.view.align) if M.options.align == "message" then - table.insert(line, Token(string.rep(sep, #annote[1]))) + local len = vim.fn.strwidth(annote[1]) + table.insert(line, Token(string.rep(sep, len))) - width = width + #annote[1] + width = width + len end end return line, width @@ -452,7 +457,7 @@ function M.render_item(item, config, count) end end - -- Safeguard against lines overflow + -- We have to keep track of extra lines added in tokens to not cause a desync with hls local extra_line = 0 for s in vim.gsplit(msg, "\n", { plain = true, trimempty = true }) do @@ -460,27 +465,29 @@ function M.render_item(item, config, count) local line_ptr = 0 local prev_end = 0 local next_start = 0 + local bytes_offset = 0 for _, token in ipairs(Tokenize(s)) do if not token then break end local spacing = token[1] - prev_end + local strlen = vim.fn.strwidth(token[3]) -- cell width -- Check if the line would overflow notification window if added as it is - if line_ptr + #token[3] + spacing >= max_width - (annote and line_width(annote[1]) or 0) then + if line_ptr + strlen + spacing >= max_width - (annote and line_width(annote[1]) or 0) then if annote then line, width = Annote(line, width, annote, sep, #tokens == 0) end table.insert(tokens, Line(unpack(line))) -- push to newline next_start = token[1] - extra_line = extra_line + 1 + extra_line = extra_line + 1 -- safeguard line_ptr = 0 line = {} end local word = { - scol = (token[1] == 1 and 0 or token[1]) - next_start, + scol = token[1] - next_start, ecol = token[2] - next_start + 1, text = token[3], hl = hl @@ -495,12 +502,11 @@ function M.render_item(item, config, count) -- paint the whole line if ts.scol == 0 and ts.ecol == 0 or - ts.scol - next_start < word.ecol and ts.ecol - next_start + 1 > word.scol - then + word.scol >= ts.scol - next_start - bytes_offset then -- Removes concealed token if M.options.hide_conceal then if ts.hl == vim.fn.hlID("conceal") then - line_ptr = line_ptr - #word.text + line_ptr = line_ptr - strlen word.text = "" end end @@ -513,8 +519,12 @@ function M.render_item(item, config, count) end end end + -- Stores extra bytes needed to sync hls + if #token[3] > strlen then + bytes_offset = bytes_offset + #token[3] + end prev_end = token[2] + 1 - line_ptr = line_ptr + #token[3] + spacing + line_ptr = line_ptr + strlen + spacing width = math.max(width, line_ptr + line_margin()) end if annote then diff --git a/tests/notify.lua b/tests/notify.lua index e6e6802..5cbf998 100644 --- a/tests/notify.lua +++ b/tests/notify.lua @@ -3,14 +3,19 @@ local notif = require("fidget.notification") local str = { line_msg = "A notification message!", + line_msg_utf8 = "󰢱 こんにちは – Hello Привет  – سلام !", line_msg_log = "A notification message with log level!", line_msg_annote = "A notification message with annote!", line_msg_markdown = "A **notification** message `with` ~log~ markdown!", + line_msg_markdown_utf8 = "󰢱 **こんにちは** – ~Bye~ Hello [Привет]  – سلام !", long_line_msg = "This is a very very long line that stretches beyond the usual " .. "notification limit and is meant to test how the UI handles overflow.", long_line_markdown = "This is a very **very** long line that stretches `beyond` the usual " .. "notification ~limit~ and is meant to [test] how the _UI_ handles overflow.", + long_line_markdown_utf8 = "This is a very **very** long `こんにちは世界` ! " .. + "🌍🚀✨💡🔔🎉🌟🌈🌹🍀🍕🎵📚🔬🖌️🛠️🎨🗝️⚙️🧰🧲 notification ~limit~ and is meant to [t€st] overfløw.", multiline_msg = "align message style\nlooks like this\nwhen reflowed", + multiline_msg_utf8 = "align message🎵 style\nlooks🌈🌹🍀🍕like this\nwh€n reflowed", block_lua = [[ -- This is a `lua` function! @@ -36,11 +41,11 @@ func main() { **Features in bold** - Lightweight and fast - Easy to integrate with `this feature` -- Open sourced +- Open sourced  > Some notes here, and in a "quote". -Sample of `code` here: +Sample of `cøde` here: ```lua function foo() print("some text here") @@ -54,6 +59,11 @@ local function line_msg() notif.notify(str.line_msg) end +-- a notification window with a message, support utf8 +local function line_msg_utf8() + notif.notify(str.line_msg_utf8) +end + -- a notification window with INFO annote local function line_msg_log() notif.notify(str.line_msg_log, 2) @@ -69,6 +79,11 @@ local function line_msg_markdown() notif.notify(str.line_msg_markdown) end +-- a notification window with markdown highlight, tags removed, support utf8 +local function line_msg_markdown_utf8() + notif.notify(str.line_msg_markdown_utf8) +end + -- a notification window with markdown highlight, tags visible local function line_msg_markdown_show_conceal() notif.view.options.hide_conceal = false @@ -121,11 +136,23 @@ local function long_line_msg_markdown() notif.notify(str.long_line_markdown) end +-- a notification window, single line overflow split in multi lines +-- each lines are highlighted using markdown_inline even when resized +-- support utf8 +local function long_line_msg_markdown_utf8() + notif.notify(str.long_line_markdown_utf8) +end + -- a notification window, multi lines local function multi_line_msg() notif.notify(str.multiline_msg) end +-- a notification window, multi lines, utf8 +local function multi_line_msg_utf8() + notif.notify(str.multiline_msg_utf8) +end + -- a notification window, multi lines with foo annote and aligned by annote local function multi_line_msg_annote() notif.view.options.align = "annote" @@ -190,13 +217,15 @@ end --- local M = { - --- name test time offset - ---@type table + --- name test time offset + ---@type { [1]: string, [2]: function, [3]: number|nil } test = { { "line_msg", line_msg, 0 }, + { "line_msg_utf8", line_msg_utf8, 0 }, { "line_msg_log", line_msg_log, 0 }, { "line_msg_annote", line_msg_annote, 0 }, { "line_msg_markdown", line_msg_markdown, 0 }, + { "line_msg_markdown_utf8", line_msg_markdown_utf8, 0 }, { "line_msg_markdown_show_conceal", line_msg_markdown_show_conceal, 0 }, { "line_msg_markdown_highlight_off", line_msg_markdown_highlight_off, 0 }, { "line_msg_colorscheme", line_msg_colorscheme, 0 }, @@ -206,8 +235,10 @@ local M = { { "long_line_msg_annote", long_line_msg_annote, 1 }, { "long_line_msg_align", long_line_msg_align, 0 }, { "long_line_msg_markdown", long_line_msg_markdown, 0 }, + { "long_line_msg_markdown_utf8", long_line_msg_markdown_utf8, 0 }, { "clear", clear, 4 }, { "multiline_msg", multi_line_msg, 0 }, + { "multiline_msg_utf8", multi_line_msg_utf8, 0 }, { "multi_line_msg_annote", multi_line_msg_annote, 0 }, { "multi_line_msg_align", multi_line_msg_align, 0 }, { "multi_line_msg_overflow", multi_line_msg_overflow, 0 }, From 73582e48c831037d84c966653f717948f9da7328 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Thu, 22 Jan 2026 22:59:54 +0200 Subject: [PATCH 11/38] fix: lines overflow when max_width > window width --- lua/fidget/notification/view.lua | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 4cc6997..b285c97 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -154,6 +154,18 @@ local function line_width(...) return w == 0 and w or w + line_margin() end +---@return number +local function window_max() + local pad = line_margin() + 4 + local win = window.max_width() - pad + local ed = vim.opt.columns:get() - pad + -- We ditch math.huge constant here because we need a limit to split lines + if win <= 0 or ed < win then + return ed + end + return win +end + --- Tokenize a string into a list of tokens. --- --- A token is a contiguous sequence of characters or an individual non-space character. @@ -436,16 +448,6 @@ function M.render_item(item, config, count) end table.insert(hl, normal_hl()) - local width = 0 - local max_width = window.options.max_width - if max_width <= 0 then - max_width = vim.opt.columns:get() - line_margin() - 4 - end - - local tokens = {} - local annote = item.annote and Token(item.annote, item.style) - local sep = config.annote_separator or " " - local hls if M.options.highlight and M.options.highlight ~= "" then hls = Highlight(msg, M.options.highlight) @@ -456,10 +458,16 @@ function M.render_item(item, config, count) end end end - -- We have to keep track of extra lines added in tokens to not cause a desync with hls local extra_line = 0 + local tokens = {} + local annote = item.annote and Token(item.annote, item.style) + local sep = config.annote_separator or " " + + local width = 0 + local max_width = window_max() + for s in vim.gsplit(msg, "\n", { plain = true, trimempty = true }) do local line = {} local line_ptr = 0 From 369bec05a5f3e4f5e14d84dc09876c707eae3349 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:01:22 +0200 Subject: [PATCH 12/38] feat: tokenize tab as space (see window.tabstop) --- lua/fidget/notification/view.lua | 11 ++++++++--- tests/notify.lua | 14 +++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index b285c97..fb7f1b9 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -177,6 +177,7 @@ end ---@return Token[] local function Tokenize(source) local pos = 0 + local tab = 1 local res = {} local len = vim.fn.strchars(source) @@ -196,11 +197,15 @@ local function Tokenize(source) table.insert(word, c) ptr = ptr + 1 end - table.insert(res, { pos, ptr, table.concat(word) }) + table.insert(res, { pos + tab, ptr + tab, table.concat(word) }) pos = ptr + 1 else - if not char:match("%s") then - table.insert(res, { pos, pos, char }) + if not char:match("%s") or char == "\t" then + if char == "\t" then + tab = tab + window.options.tabstop + else + table.insert(res, { pos + tab, pos + tab, char }) + end end pos = pos + 1 end diff --git a/tests/notify.lua b/tests/notify.lua index 5cbf998..304d4f7 100644 --- a/tests/notify.lua +++ b/tests/notify.lua @@ -3,6 +3,7 @@ local notif = require("fidget.notification") local str = { line_msg = "A notification message!", + line_msg_tab = "A notification\tmessage\twith\ttab!", line_msg_utf8 = "󰢱 こんにちは – Hello Привет  – سلام !", line_msg_log = "A notification message with log level!", line_msg_annote = "A notification message with annote!", @@ -31,7 +32,7 @@ package main import "fmt" func main() { - // this is a comment + // this is a comment fmt.Println("Hello, world!") } ]], @@ -48,7 +49,7 @@ func main() { Sample of `cøde` here: ```lua function foo() - print("some text here") + print("some tab here") end ``` ]] @@ -59,6 +60,12 @@ local function line_msg() notif.notify(str.line_msg) end +-- a notification window with a message, tab indented +local function line_msg_tab() + notif.window.options.tabstop = 4 + notif.notify(str.line_msg_tab) +end + -- a notification window with a message, support utf8 local function line_msg_utf8() notif.notify(str.line_msg_utf8) @@ -218,9 +225,10 @@ end --- local M = { --- name test time offset - ---@type { [1]: string, [2]: function, [3]: number|nil } + ---@type table { [1]: string, [2]: function, [3]: number|nil } test = { { "line_msg", line_msg, 0 }, + { "line_msg_tab", line_msg_tab, 0 }, { "line_msg_utf8", line_msg_utf8, 0 }, { "line_msg_log", line_msg_log, 0 }, { "line_msg_annote", line_msg_annote, 0 }, From c8cf9297ae2c3862ec3f59b77cf244b7f6a796b8 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:47:59 +0200 Subject: [PATCH 13/38] feat: return unified message object from view render Reorganise view render to return a "Notification". This is the first step to introduce per-message options via notify. --- lua/fidget/notification.lua | 6 +- lua/fidget/notification/view.lua | 111 +++++++++++++++++++---------- lua/fidget/notification/window.lua | 105 ++++++++++++++++----------- tests/notify.lua | 1 + 4 files changed, 141 insertions(+), 82 deletions(-) diff --git a/lua/fidget/notification.lua b/lua/fidget/notification.lua index 0b94f6c..54f68b6 100644 --- a/lua/fidget/notification.lua +++ b/lua/fidget/notification.lua @@ -364,15 +364,15 @@ notification.poller = poll.Poller { poll = function(self) notification.model.tick(self:now(), state) - local lines, width = notification.view.render(self:now(), state.groups) + local message = notification.view.render(self:now(), state.groups) - if #lines > 0 then + if #message.lines > 0 then if state.view_suppressed then return true end notification.window.guard(function() - notification.window.set_lines(lines, width) + notification.window.set_lines(message) end) return true else diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index fb7f1b9..7fabf3b 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -8,8 +8,29 @@ local logger = require("fidget.logger") ---@type Cache local cache = require("fidget.notification.model").cache() +---@class Notification +---@field opts NotificationOpts rendering options +---@field lines NotificationLine[] lines to place into buffer +---@field rows integer total amount of lines +---@field width integer width of longest line + +---@class NotificationOpts +---@field upwards boolean display from bottom to top + --- A list of highlighted tokens. ----@class NotificationLine : NotificationToken[] +---@alias NotificationLine NotificationTokens[]|NotificationItems[] + +--- NOTE: may need to double check this up +--- not sure I wrote the lls docs properly its a lot of nested table and cases +--- +---@alias NotificationTokens { hdr: NotificationToken[] } +---@alias NotificationItems { line: NotificationItem[] } + +---@class NotificationItem +---@field ecol integer +---@field scol integer +---@field text string +---@field hl string[] --- A tuple consisting of some text and a stack of highlights. ---@class NotificationToken : {[1]: string, [2]: string[]} @@ -177,7 +198,7 @@ end ---@return Token[] local function Tokenize(source) local pos = 0 - local tab = 1 + local tab = 0 local res = {} local len = vim.fn.strchars(source) @@ -227,7 +248,7 @@ end --- Pack a notification token inside margin and returns a notification line. --- ----@param ... NotificationToken +---@param ... NotificationToken|NotificationItem ---@return NotificationLine local function Line(...) if select("#", ...) == 0 then @@ -255,14 +276,13 @@ local function Annote(line, width, annote, sep, first) if first then annote[1] = sep .. annote[1] table.insert(line, annote) - width = width + line_width(annote[1]) else -- Indent messages longer than a single line (see notification.view.align) if M.options.align == "message" then local len = vim.fn.strwidth(annote[1]) - table.insert(line, Token(string.rep(sep, len))) - + local pad = Token(string.rep(sep, len)) + table.insert(line, pad) width = width + len end end @@ -363,7 +383,7 @@ function M.render_group_separator() if not line then return nil, 0 end - return { Line(Token(line, M.options.group_separator_hl)) }, line_width(line) + return { hdr = Line(Token(line, M.options.group_separator_hl)) }, line_width(line) end --- Render the header of a group, containing group name and icon. @@ -396,16 +416,16 @@ function M.render_group_header(now, group) local sep_tok = Token(M.options.icon_separator) local width = line_width(group_name, group_icon, M.options.icon_separator) if group.config.icon_on_left then - return { Line(icon_tok, sep_tok, name_tok) }, width + return { hdr = Line(icon_tok, sep_tok, name_tok) }, width else - return { Line(name_tok, sep_tok, icon_tok) }, width + return { hdr = Line(name_tok, sep_tok, icon_tok) }, width end elseif name_tok then ---@cast group_name string - return { Line(name_tok) }, line_width(group_name) + return { hdr = Line(name_tok) }, line_width(group_name) elseif icon_tok then ---@cast group_icon string - return { Line(icon_tok) }, line_width(group_icon) + return { hdr = Line(icon_tok) }, line_width(group_icon) else -- No group header to render return nil, 0 @@ -434,8 +454,8 @@ end ---@param item Item ---@param config Config ---@param count number ----@return NotificationLine[]|nil lines ----@return integer max_width +---@return NotificationItems[]|nil lines +---@return integer width function M.render_item(item, config, count) if item.hidden then return nil, 0 @@ -466,6 +486,7 @@ function M.render_item(item, config, count) -- We have to keep track of extra lines added in tokens to not cause a desync with hls local extra_line = 0 + ---@type NotificationItem[]|NotificationToken[] local tokens = {} local annote = item.annote and Token(item.annote, item.style) local sep = config.annote_separator or " " @@ -499,6 +520,7 @@ function M.render_item(item, config, count) line = {} end + ---@type NotificationItem local word = { scol = token[1] - next_start, ecol = token[2] - next_start + 1, @@ -549,15 +571,14 @@ function M.render_item(item, config, count) if #tokens == 0 and annote then tokens = { Line(annote) } end - return tokens, width + return { line = tokens }, width end --- Render notifications into lines and highlights. --- ---@param now number timestamp of current render frame ---@param groups Group[] ----@return NotificationLine[] lines ----@return integer width +---@return Notification function M.render(now, groups) is_multigrid_ui = M.check_multigrid_ui() @@ -593,25 +614,27 @@ function M.render(now, groups) end end - local icon = group.config.icon - if type(icon) == "function" then - icon = group.config.icon(now, group.items) - end - local hdr, hdr_width - if cache.group_header - and not resized - and cache.group_header[group.config.name] - and cache.group_header[group.config.name].icon == icon - then - hdr = cache.group_header[group.config.name].hdr - hdr_width = cache.group_header[group.config.name].width - else - hdr, hdr_width = M.render_group_header(now, group) - cache.group_header[group.config.name] = { hdr = hdr, width = hdr_width, icon = icon } - end - if hdr then - table.insert(chunks, hdr) - max_width = math.max(max_width, hdr_width) + if group.config.name then + local icon = group.config.icon + if type(icon) == "function" then + icon = group.config.icon(now, group.items) + end + local hdr, hdr_width + if cache.group_header + and not resized + and cache.group_header[group.config.name] + and cache.group_header[group.config.name].icon == icon + then + hdr = cache.group_header[group.config.name].hdr + hdr_width = cache.group_header[group.config.name].width + else + hdr, hdr_width = M.render_group_header(now, group) + cache.group_header[group.config.name] = { hdr = hdr, width = hdr_width, icon = icon } + end + if hdr then + table.insert(chunks, hdr) + max_width = math.max(max_width, hdr_width) + end end local items, counts = M.dedup_items(group.items) @@ -649,13 +672,25 @@ function M.render(now, groups) start, stop, step = 1, #chunks, 1 end + local rows = 0 local lines = {} for i = start, stop, step do - for _, line in ipairs(chunks[i]) do - table.insert(lines, line) + ---@cast chunks NotificationLine + if chunks[i] and (chunks[i].hdr or chunks[i].line) then + rows = rows + (chunks[i].hdr and 1 or 0) + (chunks[i].line and #chunks[i].line or 0) + table.insert(lines, chunks[i]) end end - return lines, max_width + ---@type Notification + return { + rows = rows, + lines = lines, + width = max_width, + ---@type NotificationOpts + opts = { + upwards = M.options.stack_upwards, + } + } end --- Display notification items in Neovim messages. diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index cc6f992..cde8dae 100644 --- a/lua/fidget/notification/window.lua +++ b/lua/fidget/notification/window.lua @@ -566,13 +566,42 @@ function M.show(height, width) return M.get_window(row, col, anchor, relative, width, height) end ---- Replace the set of lines in the Fidget window, right-justify them, and apply ---- highlights. ---- +---@param buf integer +---@param ns integer +---@param row integer +---@param text any[] +---@param width integer +local function set_extmark(buf, ns, row, text, width) + if vim.fn.has("nvim-0.11.0") == 1 then + vim.api.nvim_buf_set_extmark(buf, ns, row, 0, { + virt_text = text, + virt_text_pos = "eol_right_align" + }) + else + -- pre-0.11.0: eol_right_align was only introduced in 0.11.0; + -- without it we need to compute and add the padding ourselves + local len, padded = 0, { {} } + for _, tok in ipairs(text) do + len = len + vim.fn.strwidth(tok[1]) + vim.fn.count(tok[1], "\t") * math.max(0, M.options.tabstop - 1) + table.insert(padded, tok) + end + local pad_width = math.max(0, width - len) + if pad_width > 0 then + padded[1] = { string.rep(" ", pad_width), {} } + else + padded = text + end + vim.api.nvim_buf_set_extmark(buf, ns, row, 0, { + virt_text = padded, + virt_text_pos = "eol", + }) + end +end + +--- Replace the set of lines in the Fidget window, justify them, and apply highlights. --- ----@param lines NotificationLine[] lines to place into buffer ----@param width integer width of longest line -function M.set_lines(lines, width) +---@param message Notification +function M.set_lines(message) local buffer_id = M.get_buffer() local namespace_id = M.get_namespace() @@ -580,51 +609,45 @@ function M.set_lines(lines, width) vim.api.nvim_buf_clear_namespace(buffer_id, namespace_id, 0, -1) -- Prepare empty lines for extmarks - local empty_lines = vim.tbl_map(function() return "" end, lines) + local empty_lines = {} + for _ = 1, message.rows, 1 do + table.insert(empty_lines, "") + end vim.api.nvim_buf_set_lines(buffer_id, 0, -1, false, empty_lines) - for index, line in ipairs(lines) do - local prev_ecol = 0 + local row = 0 -- top to bottom + + for _, body in ipairs(message.lines) do local chunk = {} - for _, t in ipairs(line) do - if t.text then - if prev_ecol < t.scol then - table.insert(chunk, { string.rep(" ", t.scol - prev_ecol) }) + if body.line then + for _, token in ipairs(body.line) do + chunk = {} + local prev_ecol = 0 + + for _, t in ipairs(token) do + if t.text then + if prev_ecol < t.scol then + table.insert(chunk, { string.rep(" ", t.scol - prev_ecol), t.hl }) + end + table.insert(chunk, { t.text, t.hl }) + prev_ecol = t.ecol + else + table.insert(chunk, t) -- backward compatibility + end end - table.insert(chunk, { t.text, t.hl }) - prev_ecol = t.ecol - else - table.insert(chunk, t) -- backward compatibility + set_extmark(buffer_id, namespace_id, row, chunk, message.width) + row = row + 1 end - end - - if vim.fn.has("nvim-0.11.0") == 1 then - vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, index - 1, 0, { - virt_text = chunk, - virt_text_pos = "eol_right_align", - }) else - -- pre-0.11.0: eol_right_align was only introduced in 0.11.0; - -- without it we need to compute and add the padding ourselves - local len, padded = 0, { {} } - for _, tok in ipairs(chunk) do - len = len + vim.fn.strwidth(tok[1]) + vim.fn.count(tok[1], "\t") * math.max(0, M.options.tabstop - 1) - table.insert(padded, tok) - end - local pad_width = math.max(0, width - len) - if pad_width > 0 then - padded[1] = { string.rep(" ", pad_width), {} } - else - padded = chunk + for _, hdr in ipairs(body.hdr) do + table.insert(chunk, hdr) end - vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, index - 1, 0, { - virt_text = padded, - virt_text_pos = "eol", - }) + set_extmark(buffer_id, namespace_id, row, chunk, message.width) + row = row + 1 end end - M.show(vim.api.nvim_buf_line_count(buffer_id), width) + M.show(vim.api.nvim_buf_line_count(buffer_id), message.width) end --- Close the Fidget window and associated buffers. diff --git a/tests/notify.lua b/tests/notify.lua index 304d4f7..df71b13 100644 --- a/tests/notify.lua +++ b/tests/notify.lua @@ -267,6 +267,7 @@ function M.run() logger.options.level = vim.log.levels.DEBUG logger.debug("-- test --") + -- notif.view.options.stack_upwards = true -- notif.view.options.line_margin = 8 -- notif.default_config.ttl = 500 From d47755e44c7f7d7f75897b5a0694d5e3f99dc151 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 31 Jan 2026 13:14:49 +0200 Subject: [PATCH 14/38] feat: add left-aligned text support Related to #244 Position of the text inside the window can be changed with: - notification.view.text_position = "left"|"right" If in future change, we want to implement different window position or a double notification window (by duplicating states), each message can be aligned independently under "eol" or "eol_right_align". --- lua/fidget/notification/view.lua | 29 +++++++++++++++++++++++------ lua/fidget/notification/window.lua | 22 ++++++++++++++++------ tests/notify.lua | 1 + 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 7fabf3b..d252f32 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -16,6 +16,7 @@ local cache = require("fidget.notification.model").cache() ---@class NotificationOpts ---@field upwards boolean display from bottom to top +---@field position string virtual text position --- A list of highlighted tokens. ---@alias NotificationLine NotificationTokens[]|NotificationItems[] @@ -47,6 +48,11 @@ M.options = { ---@type boolean stack_upwards = true, + --- Position of the text inside the window + --- + ---@type "left"|"right" + text_position = "right", + --- Automatically highlight notification using tree-sitter --- ---@type string|false @@ -267,22 +273,31 @@ end ---@param width integer ---@param annote NotificationToken ---@param first boolean +---@param left boolean ---@return table line ---@return integer width -local function Annote(line, width, annote, sep, first) +local function Annote(line, width, annote, sep, first, left) if not annote then return line, width end if first then - annote[1] = sep .. annote[1] - table.insert(line, annote) + annote[1] = left and annote[1] .. sep or sep .. annote[1] + if left then + line = { annote, unpack(line) } + else + table.insert(line, annote) + end width = width + line_width(annote[1]) else -- Indent messages longer than a single line (see notification.view.align) if M.options.align == "message" then local len = vim.fn.strwidth(annote[1]) local pad = Token(string.rep(sep, len)) - table.insert(line, pad) + if left then + line = { pad, unpack(line) } + else + table.insert(line, pad) + end width = width + len end end @@ -489,6 +504,7 @@ function M.render_item(item, config, count) ---@type NotificationItem[]|NotificationToken[] local tokens = {} local annote = item.annote and Token(item.annote, item.style) + local left = M.options.text_position == "left" local sep = config.annote_separator or " " local width = 0 @@ -511,7 +527,7 @@ function M.render_item(item, config, count) -- Check if the line would overflow notification window if added as it is if line_ptr + strlen + spacing >= max_width - (annote and line_width(annote[1]) or 0) then if annote then - line, width = Annote(line, width, annote, sep, #tokens == 0) + line, width = Annote(line, width, annote, sep, #tokens == 0, left) end table.insert(tokens, Line(unpack(line))) -- push to newline next_start = token[1] @@ -563,7 +579,7 @@ function M.render_item(item, config, count) width = math.max(width, line_ptr + line_margin()) end if annote then - line, width = Annote(line, width, annote, sep, #tokens == 0) + line, width = Annote(line, width, annote, sep, #tokens == 0, left) end table.insert(tokens, Line(unpack(line))) end @@ -689,6 +705,7 @@ function M.render(now, groups) ---@type NotificationOpts opts = { upwards = M.options.stack_upwards, + position = M.options.text_position, } } end diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index cde8dae..353940b 100644 --- a/lua/fidget/notification/window.lua +++ b/lua/fidget/notification/window.lua @@ -570,24 +570,32 @@ end ---@param ns integer ---@param row integer ---@param text any[] +---@param pos string ---@param width integer -local function set_extmark(buf, ns, row, text, width) +local function set_extmark(buf, ns, row, text, pos, width) if vim.fn.has("nvim-0.11.0") == 1 then vim.api.nvim_buf_set_extmark(buf, ns, row, 0, { virt_text = text, - virt_text_pos = "eol_right_align" + virt_text_pos = pos == "left" and "eol" or "eol_right_align" }) else + local left = pos == "left" -- pre-0.11.0: eol_right_align was only introduced in 0.11.0; -- without it we need to compute and add the padding ourselves - local len, padded = 0, { {} } + local len = 0 + local padded = left and {} or { {} } for _, tok in ipairs(text) do len = len + vim.fn.strwidth(tok[1]) + vim.fn.count(tok[1], "\t") * math.max(0, M.options.tabstop - 1) table.insert(padded, tok) end local pad_width = math.max(0, width - len) if pad_width > 0 then - padded[1] = { string.rep(" ", pad_width), {} } + local pad = string.rep(" ", pad_width) + if left then + table.insert(padded, { pad, {} }) + else + padded[1] = { pad, {} } + end else padded = text end @@ -620,6 +628,7 @@ function M.set_lines(message) for _, body in ipairs(message.lines) do local chunk = {} + ---@cast body NotificationItems if body.line then for _, token in ipairs(body.line) do chunk = {} @@ -636,14 +645,15 @@ function M.set_lines(message) table.insert(chunk, t) -- backward compatibility end end - set_extmark(buffer_id, namespace_id, row, chunk, message.width) + set_extmark(buffer_id, namespace_id, row, chunk, message.opts.position, message.width) row = row + 1 end else + ---@cast body NotificationTokens for _, hdr in ipairs(body.hdr) do table.insert(chunk, hdr) end - set_extmark(buffer_id, namespace_id, row, chunk, message.width) + set_extmark(buffer_id, namespace_id, row, chunk, message.opts.position, message.width) row = row + 1 end end diff --git a/tests/notify.lua b/tests/notify.lua index df71b13..938d2de 100644 --- a/tests/notify.lua +++ b/tests/notify.lua @@ -268,6 +268,7 @@ function M.run() logger.debug("-- test --") -- notif.view.options.stack_upwards = true + -- notif.view.options.text_position = "left" -- notif.view.options.line_margin = 8 -- notif.default_config.ttl = 500 From 475fc7ac11d4cf380a8302d531624f3cc13388b1 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:59:05 +0200 Subject: [PATCH 15/38] feat: add per-message text alignment to notify Related to #244 A notification can now have different visual properties. This apply only to text inside the window, for now only one window is supported. Users can now let each message be independently aligned to the left or right: - `vim.notify([[block of code]], nil, { position = "left" })` Message header (group name, sep etc) takes value from config instead. --- lua/fidget/notification.lua | 13 +++++++--- lua/fidget/notification/model.lua | 2 ++ lua/fidget/notification/view.lua | 18 ++++++++++--- lua/fidget/notification/window.lua | 4 ++- tests/notify.lua | 41 ++++++++++++++++++++++++++---- 5 files changed, 65 insertions(+), 13 deletions(-) diff --git a/lua/fidget/notification.lua b/lua/fidget/notification.lua index 54f68b6..14d00b6 100644 --- a/lua/fidget/notification.lua +++ b/lua/fidget/notification.lua @@ -21,6 +21,7 @@ local logger = require("fidget.logger") ---@field key Key|nil Replace existing notification item of the same key ---@field group Key|nil Group that this notification item belongs to ---@field annote string|nil Optional single-line title that accompanies the message +---@field position string|nil Optional text position inside the window ---@field hidden boolean|nil Whether this item should be shown ---@field ttl number|nil How long after a notification item should exist; pass 0 to use default value ---@field update_only boolean|nil If true, don't create new notification items @@ -76,6 +77,7 @@ local logger = require("fidget.logger") ---@field content_key Key What to deduplicate items by (do not deduplicate if `nil`) ---@field message string Displayed message for the item ---@field annote string|nil Optional title that accompanies the message +---@field position string|nil Optional text position inside the window ---@field style string Style used to render the annote/title, if any ---@field hidden boolean Whether this item should be shown ---@field expires_at number What time this item should be removed; math.huge means never @@ -156,8 +158,8 @@ notification.default_config = { --- Sets a |fidget.notification.Item|'s `content_key`, for deduplication. --- --- This default implementation sets an item's `content_key` to its `message`, ---- appended with its `annote` (or a null byte if it has no `annote`), a rough ---- "hash" of its contents. You can write your own `update_hook` that "hashes" +--- appended with its `position` and `annote` (or a null byte if it has no `annote`), +--- a rough "hash" of its contents. You can write your own `update_hook` that "hashes" --- the message differently, e.g., only considering the `message`, or taking the --- `data` or style fields into account. --- @@ -181,7 +183,12 @@ notification.default_config = { --- ---@param item Item function notification.set_content_key(item) - item.content_key = item.message .. " " .. (item.annote and item.annote or string.char(0)) + item.content_key = string.format( + "%s-%s%s", + item.message, + item.position and item.position or "", + item.annote and item.annote or string.char(0) + ) end ---@options notification [[ diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index 1c96c91..bfd7568 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -278,6 +278,7 @@ function M.update(now, configs, state, msg, level, opts) group_key = group_key, message = msg, annote = opts.annote or annote_from_level(group.config, level), + position = opts.position or nil, style = style_from_level(group.config, level) or group.config.annote_style or "Question", hidden = opts.hidden or false, expires_at = compute_expiry(now, opts.ttl, group.config.ttl), @@ -295,6 +296,7 @@ function M.update(now, configs, state, msg, level, opts) item.message = msg or item.message item.style = style_from_level(group.config, level) or item.style item.annote = opts.annote or annote_from_level(group.config, level) or item.annote + item.position = opts.position or item.position item.hidden = opts.hidden or item.hidden item.expires_at = opts.ttl and compute_expiry(now, opts.ttl, group.config.ttl) or item.expires_at item.skip_history = opts.skip_history or item.skip_history diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index d252f32..943ef87 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -25,7 +25,7 @@ local cache = require("fidget.notification.model").cache() --- not sure I wrote the lls docs properly its a lot of nested table and cases --- ---@alias NotificationTokens { hdr: NotificationToken[] } ----@alias NotificationItems { line: NotificationItem[] } +---@alias NotificationItems { line: NotificationItem[], opts: NotificationItemOpts } ---@class NotificationItem ---@field ecol integer @@ -33,6 +33,10 @@ local cache = require("fidget.notification.model").cache() ---@field text string ---@field hl string[] +--- Per-message rendering options +---@class NotificationItemOpts +---@field position string + --- A tuple consisting of some text and a stack of highlights. ---@class NotificationToken : {[1]: string, [2]: string[]} @@ -469,7 +473,7 @@ end ---@param item Item ---@param config Config ---@param count number ----@return NotificationItems[]|nil lines +---@return NotificationItems|nil lines ---@return integer width function M.render_item(item, config, count) if item.hidden then @@ -504,7 +508,7 @@ function M.render_item(item, config, count) ---@type NotificationItem[]|NotificationToken[] local tokens = {} local annote = item.annote and Token(item.annote, item.style) - local left = M.options.text_position == "left" + local left = item.position and item.position == "left" or M.options.text_position == "left" local sep = config.annote_separator or " " local width = 0 @@ -587,7 +591,13 @@ function M.render_item(item, config, count) if #tokens == 0 and annote then tokens = { Line(annote) } end - return { line = tokens }, width + return { + line = tokens, + --- Options to be passed to window render for this notification + --- For now only text position is used + ---@type NotificationItemOpts + opts = { position = item.position } + }, width end --- Render notifications into lines and highlights. diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index 353940b..ddbbce0 100644 --- a/lua/fidget/notification/window.lua +++ b/lua/fidget/notification/window.lua @@ -630,6 +630,8 @@ function M.set_lines(message) ---@cast body NotificationItems if body.line then + local position = body.opts and body.opts.position or message.opts.position + for _, token in ipairs(body.line) do chunk = {} local prev_ecol = 0 @@ -645,7 +647,7 @@ function M.set_lines(message) table.insert(chunk, t) -- backward compatibility end end - set_extmark(buffer_id, namespace_id, row, chunk, message.opts.position, message.width) + set_extmark(buffer_id, namespace_id, row, chunk, position, message.width) row = row + 1 end else diff --git a/tests/notify.lua b/tests/notify.lua index 938d2de..798c78a 100644 --- a/tests/notify.lua +++ b/tests/notify.lua @@ -76,6 +76,11 @@ local function line_msg_log() notif.notify(str.line_msg_log, 2) end +-- a notification window with INFO annote, left aligned +local function line_msg_left() + notif.notify(str.line_msg, 2, { position = "left" }) +end + -- a notification window with foo annote local function line_msg_annote() notif.notify(str.line_msg_annote, nil, { annote = "foo" }) @@ -116,6 +121,11 @@ local function long_line_msg() notif.notify(str.long_line_msg) end +-- a notification window, single line overflow split in multi lines, left aligned +local function long_line_msg_left() + notif.notify(str.long_line_msg, nil, { position = "left" }) +end + -- a notification window, single line overflow split in multi lines -- respect max_width with no overflow local function long_line_msg_resized() @@ -178,21 +188,21 @@ local function multi_line_msg_overflow() notif.notify(str.multiline_msg .. "\n" .. str.long_line_msg, 2) end --- a notification window with an highlighted [[block of lua code]] +-- a notification window with an highlighted [[block of lua code]], left aligned -- colors should be the same as filetype=lua local function block_msg_lua() notif.view.options.highlight = "lua" - notif.notify(str.block_lua) + notif.notify(str.block_lua, nil, { position = "left" }) end --- a notification window with an highlighted [[block of go code]] +-- a notification window with an highlighted [[block of go code]], left aligned -- colors should be the same as filetype=go local function block_msg_go() notif.view.options.highlight = "go" - notif.notify(str.block_go) + notif.notify(str.block_go, nil, { position = "left" }) end --- a notification window with an highlighted [[block of markdown text]] +-- a notification window with an highlighted [[block of markdown text]], right aligned -- colors should be the same as filetype=markdown -- -- NOTE: mixing highlight with ```lang\n text``` is not yet supported @@ -216,6 +226,18 @@ local function empty_annote() notif.notify("empty annote ->", nil, { annote = "" }) end +-- a notification window without group name +local function empty_name() + notif.default_config.name = nil + notif.notify("empty group name") +end + +-- a notification window with a group name but no icon +local function empty_icon() + notif.default_config.icon = nil + notif.notify("empty group icon") +end + -- the notification window is cleared and closed local function clear() notif.clear() @@ -231,6 +253,7 @@ local M = { { "line_msg_tab", line_msg_tab, 0 }, { "line_msg_utf8", line_msg_utf8, 0 }, { "line_msg_log", line_msg_log, 0 }, + { "line_msg_left", line_msg_left, 0 }, { "line_msg_annote", line_msg_annote, 0 }, { "line_msg_markdown", line_msg_markdown, 0 }, { "line_msg_markdown_utf8", line_msg_markdown_utf8, 0 }, @@ -239,6 +262,7 @@ local M = { { "line_msg_colorscheme", line_msg_colorscheme, 0 }, { "clear", clear, 4 }, { "long_line_msg", long_line_msg, 0 }, + { "long_line_msg_left", long_line_msg_left, 0 }, { "long_line_msg_resized", long_line_msg_resized, 0 }, { "long_line_msg_annote", long_line_msg_annote, 1 }, { "long_line_msg_align", long_line_msg_align, 0 }, @@ -257,6 +281,10 @@ local M = { { "clear", clear, 1 }, { "block_msg_markdown", block_msg_markdown, 0 }, { "clear", clear, 1 }, + { "empty_name", empty_name, 0 }, + { "clear", clear, 1 }, + { "empty_icon", empty_icon, 0 }, + { "clear", clear, 1 }, { "empty_msg", empty_msg, 0 }, { "empty_msg_annote", empty_msg_annote, 0 }, { "empty_annote", empty_annote, 0 }, @@ -267,6 +295,7 @@ function M.run() logger.options.level = vim.log.levels.DEBUG logger.debug("-- test --") + notif.window.options.border = "single" -- notif.view.options.stack_upwards = true -- notif.view.options.text_position = "left" -- notif.view.options.line_margin = 8 @@ -274,6 +303,7 @@ function M.run() M.config = { colorscheme = vim.g.colors_name, + default = vim.deepcopy(notif.default_config), window = vim.deepcopy(notif.window.options), view = vim.deepcopy(notif.view.options), } @@ -296,6 +326,7 @@ function M.run() if M.config.colorscheme ~= vim.g.colors_name then vim.cmd("colorscheme " .. M.config.colorscheme) end + for k, v in pairs(M.config.default) do notif.default_config[k] = v end for k, v in pairs(M.config.window) do notif.window.options[k] = v end for k, v in pairs(M.config.view) do notif.view.options[k] = v end end From 55e04ee4c6c16e4902884ae5cb8343b14ae9c542 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:44:46 +0200 Subject: [PATCH 16/38] fix: cache window max size instead of editor size When editor size is cached and a notification is draw while "window.max_width" value changes, the renderer still uses the previously cached width and lays out the lines based on a wrong value. Lines would overflow when message position is set to "left" and max_width suddenly change without editor size changing. --- lua/fidget/notification/view.lua | 2 +- tests/notify.lua | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 943ef87..a939118 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -615,7 +615,7 @@ function M.render(now, groups) cache.group_header = cache.group_header or {} cache.render_item = cache.render_item or {} - local size = vim.opt.columns:get() + local size = window_max() -- Force rendering when the length of the window change local resized = cache.render_width and cache.render_width ~= size or false diff --git a/tests/notify.lua b/tests/notify.lua index 798c78a..741cdd2 100644 --- a/tests/notify.lua +++ b/tests/notify.lua @@ -122,6 +122,7 @@ local function long_line_msg() end -- a notification window, single line overflow split in multi lines, left aligned +-- respect max_width with no overflow when resized local function long_line_msg_left() notif.notify(str.long_line_msg, nil, { position = "left" }) end From e3404d0bb947ef059208ba838ed3101904f14d2b Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 31 Jan 2026 18:06:32 +0200 Subject: [PATCH 17/38] feat: add per-message text highlight to notify Users can now let each message be independently highlighted with different language: - `vim.notify([[block of code]], nil, { lang = "lua" })` --- lua/fidget/notification.lua | 5 ++++- lua/fidget/notification/model.lua | 2 ++ lua/fidget/notification/view.lua | 7 ++++--- tests/notify.lua | 11 +++++++++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/lua/fidget/notification.lua b/lua/fidget/notification.lua index 14d00b6..1838a01 100644 --- a/lua/fidget/notification.lua +++ b/lua/fidget/notification.lua @@ -22,6 +22,7 @@ local logger = require("fidget.logger") ---@field group Key|nil Group that this notification item belongs to ---@field annote string|nil Optional single-line title that accompanies the message ---@field position string|nil Optional text position inside the window +---@field lang string|nil Optional tree-sitter highlight language to use ---@field hidden boolean|nil Whether this item should be shown ---@field ttl number|nil How long after a notification item should exist; pass 0 to use default value ---@field update_only boolean|nil If true, don't create new notification items @@ -78,6 +79,7 @@ local logger = require("fidget.logger") ---@field message string Displayed message for the item ---@field annote string|nil Optional title that accompanies the message ---@field position string|nil Optional text position inside the window +---@field lang string|nil Optional tree-sitter highlight language to use ---@field style string Style used to render the annote/title, if any ---@field hidden boolean Whether this item should be shown ---@field expires_at number What time this item should be removed; math.huge means never @@ -184,8 +186,9 @@ notification.default_config = { ---@param item Item function notification.set_content_key(item) item.content_key = string.format( - "%s-%s%s", + "%s-%s-%s%s", item.message, + item.lang and item.lang or "", item.position and item.position or "", item.annote and item.annote or string.char(0) ) diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index bfd7568..d93c2ba 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -279,6 +279,7 @@ function M.update(now, configs, state, msg, level, opts) message = msg, annote = opts.annote or annote_from_level(group.config, level), position = opts.position or nil, + lang = opts.lang or nil, style = style_from_level(group.config, level) or group.config.annote_style or "Question", hidden = opts.hidden or false, expires_at = compute_expiry(now, opts.ttl, group.config.ttl), @@ -297,6 +298,7 @@ function M.update(now, configs, state, msg, level, opts) item.style = style_from_level(group.config, level) or item.style item.annote = opts.annote or annote_from_level(group.config, level) or item.annote item.position = opts.position or item.position + item.lang = opts.lang or item.lang item.hidden = opts.hidden or item.hidden item.expires_at = opts.ttl and compute_expiry(now, opts.ttl, group.config.ttl) or item.expires_at item.skip_history = opts.skip_history or item.skip_history diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index a939118..7778bc2 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -493,11 +493,12 @@ function M.render_item(item, config, count) table.insert(hl, normal_hl()) local hls - if M.options.highlight and M.options.highlight ~= "" then - hls = Highlight(msg, M.options.highlight) + local lang = item.lang and item.lang or M.options.highlight + if lang and lang ~= "" then + hls = Highlight(msg, lang) if hls then -- Also use inline for markdown - if M.options.highlight == "markdown" then + if lang == "markdown" then hls = Highlight(msg, "markdown_inline", hls) end end diff --git a/tests/notify.lua b/tests/notify.lua index 741cdd2..352ed65 100644 --- a/tests/notify.lua +++ b/tests/notify.lua @@ -5,6 +5,7 @@ local str = { line_msg = "A notification message!", line_msg_tab = "A notification\tmessage\twith\ttab!", line_msg_utf8 = "󰢱 こんにちは – Hello Привет  – سلام !", + line_msg_lang = "print([x**2 for x in range(1, 6)])", line_msg_log = "A notification message with log level!", line_msg_annote = "A notification message with annote!", line_msg_markdown = "A **notification** message `with` ~log~ markdown!", @@ -32,8 +33,8 @@ package main import "fmt" func main() { - // this is a comment - fmt.Println("Hello, world!") + // this is a `comment` + fmt.Println("Hello, ~world~!") } ]], block_md = [[ @@ -71,6 +72,11 @@ local function line_msg_utf8() notif.notify(str.line_msg_utf8) end +-- a notification window with a message, python highlighted +local function line_msg_lang() + notif.notify(str.line_msg_lang, nil, { lang = "python" }) +end + -- a notification window with INFO annote local function line_msg_log() notif.notify(str.line_msg_log, 2) @@ -254,6 +260,7 @@ local M = { { "line_msg_tab", line_msg_tab, 0 }, { "line_msg_utf8", line_msg_utf8, 0 }, { "line_msg_log", line_msg_log, 0 }, + { "line_msg_lang", line_msg_lang, 0 }, { "line_msg_left", line_msg_left, 0 }, { "line_msg_annote", line_msg_annote, 0 }, { "line_msg_markdown", line_msg_markdown, 0 }, From 991af7b7e060f13cb72336322f15b7fb8e3e87f9 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:27:16 +0200 Subject: [PATCH 18/38] fix: test loop stuck when g.colors_name is nil --- tests/notify.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/notify.lua b/tests/notify.lua index 352ed65..811c042 100644 --- a/tests/notify.lua +++ b/tests/notify.lua @@ -310,7 +310,7 @@ function M.run() -- notif.default_config.ttl = 500 M.config = { - colorscheme = vim.g.colors_name, + colorscheme = vim.g.colors_name or "default", default = vim.deepcopy(notif.default_config), window = vim.deepcopy(notif.window.options), view = vim.deepcopy(notif.view.options), From d167c1451b5a355ffa4573157c6f8196bef5a901 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:47:39 +0200 Subject: [PATCH 19/38] fix: cache rendered object logic Cached items are now properly dereferenced when unused. When a notification reaches its ttl expiration it is removed from the cache. When the notification window closes, all items (including headers) are cleared from the cache except for the "default" group header and separator. + minor improvement with rehashing and table allocation inside the render loop. --- lua/fidget/notification/model.lua | 76 +++++++++++++++++------------- lua/fidget/notification/view.lua | 78 ++++++++++++++----------------- 2 files changed, 78 insertions(+), 76 deletions(-) diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index d93c2ba..fa39297 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -17,42 +17,50 @@ local poll = require("fidget.poll") --- Cache used during rendering of notifications. --- +---@alias CItem NotificationLine[]|nil +--- width count +---@alias CacheItem { [1]: CItem, [2]: integer, [3]: integer } +--- icon +---@alias CacheHdr { [1]: CItem, [2]: integer, [3]: string } +---@alias CacheSep { [1]: CItem, [2]: integer } +--- ---@class Cache ----@field group_header table ----@field group_separator CacheSep ----@field render_item table ----@field render_width integer +---@field group_header table +---@field group_sep CacheSep +---@field render_item table +---@field render_width integer local cache = {} ---- Cached rendered lines: reused when `count` matches the current item group count. ---- ----@class CachedItem ----@field it NotificationLine[]|nil ----@field width integer ----@field count integer - ---- Cached rendered lines: reused when `icon` matches the current group header icon. ---- ----@class CachedHdr ----@field hdr NotificationLine[]|nil ----@field width integer ----@field icon string - ---- Cached rendered lines: reused when `group_separator` is set. ---- ----@class CacheSep ----@field sep NotificationLine[]|nil ----@field width integer - ---@return Cache function M.cache() return cache end ---- Deletes rendered lines from the cache. +--- Deletes objects from the cache. --- ----@param item Item -local function del_cached(item) - if cache.render_item and cache.render_item[item.content_key] then - cache.render_item[item.content_key] = nil +---@param item Item|Group +---@param last boolean? +local function del_cached(item, last) + ---@type Group + if item.config and item.key and item.key ~= "default" then + -- Free up non-default headers (including lsp) + cache.group_header[item.config.name] = nil + end + if last then + if next(cache.render_item) ~= nil then + -- At the last notification, free up remaining cached items + for k, _ in pairs(cache.render_item) do + cache.render_item[k] = nil + end + end + return + end + ---@type Item + local key = item.content_key or item + if cache.render_item + and cache.render_item[key] + and cache.render_item[key][3] == 1 + then + -- We do not use this item anymore (ttl reached) + cache.render_item[key] = nil end end @@ -90,8 +98,8 @@ local function get_group(configs, groups, group_key) end end - -- Group not found; create it and insert it into list of active groups. - + --- Group not found; create it and insert it into list of active groups. + --- ---@type Group local group = { key = group_key, @@ -385,12 +393,16 @@ function M.tick(now, state) local new_groups = {} for _, group in ipairs(state.groups) do local new_items = {} + -- Dereference unused items on the last notification + if #group.items == 0 then + del_cached(group, true) + end for _, item in ipairs(group.items) do if item.expires_at > now then table.insert(new_items, item) else - add_removed(state, now, group, item) del_cached(item) + add_removed(state, now, group, item) end end if #group.items > 0 then diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 7778bc2..62fd053 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -432,8 +432,8 @@ function M.render_group_header(now, group) if name_tok and icon_tok then ---@cast group_name string ---@cast group_icon string - local sep_tok = Token(M.options.icon_separator) - local width = line_width(group_name, group_icon, M.options.icon_separator) + local sep_tok = Token(M.options.icon_separator or " ") + local width = line_width(group_name, group_icon, M.options.icon_separator or " ") if group.config.icon_on_left then return { hdr = Line(icon_tok, sep_tok, name_tok) }, width else @@ -613,10 +613,12 @@ function M.render(now, groups) local chunks = {} local max_width = 0 - cache.group_header = cache.group_header or {} cache.render_item = cache.render_item or {} + cache.group_header = cache.group_header or {} + cache.group_sep = cache.group_sep or { nil, nil } -- sep, width local size = window_max() + local max = math.max -- Force rendering when the length of the window change local resized = cache.render_width and cache.render_width ~= size or false @@ -627,18 +629,11 @@ function M.render(now, groups) for idx, group in ipairs(groups) do if idx ~= 1 then - local sep, sep_width - if cache.group_separator and not resized then - sep = cache.group_separator.sep - sep_width = cache.group_separator.width - else - sep, sep_width = M.render_group_separator() - cache.group_separator = { sep = sep, width = sep_width } - end - if sep then - table.insert(chunks, sep) - max_width = math.max(max_width, sep_width) + if resized or not cache.group_sep[1] then + cache.group_sep[1], cache.group_sep[2] = M.render_group_separator() end + chunks[#chunks + 1] = cache.group_sep[1] + max_width = max(max_width, cache.group_sep[2]) end if group.config.name then @@ -646,49 +641,44 @@ function M.render(now, groups) if type(icon) == "function" then icon = group.config.icon(now, group.items) end - local hdr, hdr_width - if cache.group_header - and not resized - and cache.group_header[group.config.name] - and cache.group_header[group.config.name].icon == icon - then - hdr = cache.group_header[group.config.name].hdr - hdr_width = cache.group_header[group.config.name].width - else - hdr, hdr_width = M.render_group_header(now, group) - cache.group_header[group.config.name] = { hdr = hdr, width = hdr_width, icon = icon } + if not cache.group_header[group.config.name] then + cache.group_header[group.config.name] = { nil, nil, nil } -- hdr, width, icon end - if hdr then - table.insert(chunks, hdr) - max_width = math.max(max_width, hdr_width) + local hdr = cache.group_header[group.config.name] + + if resized or not icon or hdr and icon ~= hdr[3] then + hdr[1], hdr[2] = M.render_group_header(now, group) + hdr[3] = icon end + chunks[#chunks + 1] = hdr[1] + max_width = max(max_width, hdr[2]) end local items, counts = M.dedup_items(group.items) + for i, item in ipairs(items) do if group.config.render_limit and i > group.config.render_limit then -- Don't bother rendering the rest (though they still exist) break end - local key = item.content_key or item + local count = counts[key] + -- Caches lsp messages when update_hook is false + if not group.config.update_hook and group.config.priority then + key, count = item.message, 1 + end - local it, it_width - if cache.render_item[key] - and not resized - and counts[key] == cache.render_item[key].count - and cache.render_width == size - then - it = cache.render_item[key].it - it_width = cache.render_item[key].width - else - it, it_width = M.render_item(item, group.config, counts[key]) - cache.render_item[key] = { it = it, width = it_width, count = counts[key] } + if not cache.render_item[key] then + cache.render_item[key] = { nil, nil, nil } -- it, width, count end - if it then - table.insert(chunks, it) - max_width = math.max(max_width, it_width) + local it = cache.render_item[key] + + if resized or count ~= it[3] then + it[1], it[2] = M.render_item(item, group.config, count) + it[3] = count end + chunks[#chunks + 1] = it[1] + max_width = max(max_width, it[2]) end end @@ -705,7 +695,7 @@ function M.render(now, groups) ---@cast chunks NotificationLine if chunks[i] and (chunks[i].hdr or chunks[i].line) then rows = rows + (chunks[i].hdr and 1 or 0) + (chunks[i].line and #chunks[i].line or 0) - table.insert(lines, chunks[i]) + lines[#lines + 1] = chunks[i] end end ---@type Notification From 835521aae0f2966d648d98d1bac22a8699bec3a6 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:24:39 +0200 Subject: [PATCH 20/38] feat: skip duplicates in history if update_hook != false --- lua/fidget/notification/model.lua | 9 +++++++++ lua/fidget/notification/view.lua | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index fa39297..67208df 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -118,6 +118,15 @@ end ---@param item Item local function add_removed(state, now, group, item) if not item.skip_history then + -- Skip duplicates unless we have no items deduplication + if group.config.update_hook and #state.removed > 0 then + if state.removed[state.removed_first - 1].content_key + and state.removed[state.removed_first - 1].content_key == item.content_key + then + return + end + end + local group_name = group.config.name if type(group_name) == "function" then group_name = group_name(now, group.items) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 62fd053..51d9f99 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -462,7 +462,7 @@ function M.dedup_items(items) counts[key] = counts[key] + 1 else counts[key] = 1 - table.insert(deduped, item) + deduped[#deduped+1] = item end end return deduped, counts From 4181aeb2ad838cb0a08c23661c870193d38db6cc Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:25:04 +0200 Subject: [PATCH 21/38] perf: improve highlighter logic Cache "highlights" queries and reuse them when parsing the same language. + minor improvement in the duplicate items detection loop. --- lua/fidget/notification/view.lua | 64 +++++++++++++++++++------------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 51d9f99..9f1d397 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -8,6 +8,9 @@ local logger = require("fidget.logger") ---@type Cache local cache = require("fidget.notification.model").cache() +---@type table +local tsquery = {} + ---@class Notification ---@field opts NotificationOpts rendering options ---@field lines NotificationLine[] lines to place into buffer @@ -322,10 +325,15 @@ local function Highlight(source, lang, prev_hls) logger.warn(parser) return end - local tree = parser:parse()[1] - local query = vim.treesitter.query.get(lang, "highlights") - if not query then - return -- query file not found + local query + -- Caches highlights queries + if not tsquery[lang] then + query = vim.treesitter.query.get(lang, "highlights") + if not query then + return -- query file not found + end + else + query = tsquery[lang] end local hls = {} -- holds captured hl @@ -336,7 +344,7 @@ local function Highlight(source, lang, prev_hls) local prev_line = 0 local prev_text, prev_range - for id, node in query:iter_captures(tree:root(), source) do + for id, node in query:iter_captures(parser:parse()[1]:root(), source) do local text = vim.treesitter.get_node_text(node, source) if not text then goto continue @@ -346,8 +354,8 @@ local function Highlight(source, lang, prev_hls) goto continue -- ignores spellcheck end - -- Finds hl groups id if exists local hl = vim.fn.hlID(name) + -- Finds hl groups id if exists if hl == 0 then hl = vim.fn.hlID("@" .. name) if hl == 0 then @@ -357,9 +365,9 @@ local function Highlight(source, lang, prev_hls) local srow, scol, _, ecol = node:range() if prev_line ~= srow then - table.insert(hls, line) -- push to a new line - line = {} + hls[#hls + 1] = line -- push to a new line prev_line = srow + line = {} end if prev_text ~= text then prev_text = text @@ -371,28 +379,34 @@ local function Highlight(source, lang, prev_hls) end -- Uses the same item renderer struct for _, token in ipairs(Tokenize(text)) do - local t = { - srow = srow, - scol = scol, - ecol = ecol, - text = token[3], - hl = hl - } - if not vim.tbl_contains(line, - function(w) - return vim.deep_equal(w, t) - end, { predicate = true }) - then - table.insert(line, t) + local dup = false + -- Ignores duplicates + for i = 1, #line do + if srow == line[i].srow + and scol == line[i].scol + and ecol == line[i].ecol + and token[3] == line[i].text + and hl == line[i].hl then + dup = true + break + end + end + if not dup then + line[#line + 1] = { + srow = srow, + scol = scol, + ecol = ecol, + text = token[3], + hl = hl + } end end ::continue:: end if #line > 0 then - table.insert(hls, line) - return hls + hls[#hls + 1] = line end - return nil + return hls end ---@return NotificationLine[]|nil lines @@ -462,7 +476,7 @@ function M.dedup_items(items) counts[key] = counts[key] + 1 else counts[key] = 1 - deduped[#deduped+1] = item + deduped[#deduped + 1] = item end end return deduped, counts From 2aa7cf5673b31a893a70d57bef68e7a2b42e3d4f Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:19:21 +0200 Subject: [PATCH 22/38] perf: improve items rendering logic --- lua/fidget/notification.lua | 2 +- lua/fidget/notification/view.lua | 38 ++++++++++++++++++-------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/lua/fidget/notification.lua b/lua/fidget/notification.lua index 1838a01..bbf4770 100644 --- a/lua/fidget/notification.lua +++ b/lua/fidget/notification.lua @@ -468,7 +468,7 @@ end --- ---@return Key[] keys function notification.group_keys() - return vim.tbl_map(function(group) return group.key end, state.groups) + return vim.iter(state.groups):map(function(group) return group.key end):totable() end return notification diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 9f1d397..a968183 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -210,35 +210,39 @@ end ---@param source string ---@return Token[] local function Tokenize(source) + local strcharpart = vim.fn.strcharpart + local strchars = vim.fn.strchars + local concat = table.concat + local pos = 0 local tab = 0 local res = {} - local len = vim.fn.strchars(source) + local len = strchars(source) while pos < len do ---@type string - local char = vim.fn.strcharpart(source, pos, 1) + local char = strcharpart(source, pos, 1) if char:match("%w") then local ptr = pos local word = { char } while ptr + 1 < len do - local c = vim.fn.strcharpart(source, ptr + 1, 1) + local c = strcharpart(source, ptr + 1, 1) if not c:match("%w") then break end - table.insert(word, c) + word[#word + 1] = c ptr = ptr + 1 end - table.insert(res, { pos + tab, ptr + tab, table.concat(word) }) + res[#res + 1] = { pos + tab, ptr + tab, concat(word) } pos = ptr + 1 else if not char:match("%s") or char == "\t" then if char == "\t" then tab = tab + window.options.tabstop else - table.insert(res, { pos + tab, pos + tab, char }) + res[#res + 1] = { pos + tab, pos + tab, char } end end pos = pos + 1 @@ -292,7 +296,7 @@ local function Annote(line, width, annote, sep, first, left) if left then line = { annote, unpack(line) } else - table.insert(line, annote) + line[#line + 1] = annote end width = width + line_width(annote[1]) else @@ -303,7 +307,7 @@ local function Annote(line, width, annote, sep, first, left) if left then line = { pad, unpack(line) } else - table.insert(line, pad) + line[#line + 1] = pad end width = width + len end @@ -500,11 +504,11 @@ function M.render_item(item, config, count) return nil, 0 end - local hl = {} + local hl = { nil, nil } if not is_multigrid_ui then - table.insert(hl, window.no_blend_hl) + hl[1] = window.no_blend_hl end - table.insert(hl, normal_hl()) + hl[#hl + 1] = normal_hl() local hls local lang = item.lang and item.lang or M.options.highlight @@ -548,13 +552,12 @@ function M.render_item(item, config, count) if annote then line, width = Annote(line, width, annote, sep, #tokens == 0, left) end - table.insert(tokens, Line(unpack(line))) -- push to newline + tokens[#tokens + 1] = Line(unpack(line)) -- push to newline next_start = token[1] extra_line = extra_line + 1 -- safeguard line_ptr = 0 line = {} end - ---@type NotificationItem local word = { scol = token[1] - next_start, @@ -562,10 +565,11 @@ function M.render_item(item, config, count) text = token[3], hl = hl } - table.insert(line, word) + line[#line + 1] = word -- Adds treesitter highlights if hls then + local iter = vim.iter for _, tsline in ipairs(hls) do for _, ts in ipairs(tsline) do if ts.text == word.text and ts.srow + extra_line == #tokens then @@ -580,10 +584,10 @@ function M.render_item(item, config, count) word.text = "" end end - word.hl = vim.tbl_map(function(value) + word.hl = iter(word.hl):map(function(value) if value ~= window.no_blend_hl then value = ts.hl end return value - end, word.hl) + end):totable() end end end @@ -600,7 +604,7 @@ function M.render_item(item, config, count) if annote then line, width = Annote(line, width, annote, sep, #tokens == 0, left) end - table.insert(tokens, Line(unpack(line))) + tokens[#tokens + 1] = Line(unpack(line)) end -- The message is an empty string but there's an annotation to render if #tokens == 0 and annote then From 98ebfaf3030ea8f877b7238e629d2786f8a6cf7e Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:50:15 +0200 Subject: [PATCH 23/38] refactor: buf_set_extmark virtual text position for nvim < 0.11 Uses nvim "right_align" text position instead of computing padding ourselves. --- lua/fidget/notification/window.lua | 54 +++++++----------------------- 1 file changed, 12 insertions(+), 42 deletions(-) diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index ddbbce0..09c63f1 100644 --- a/lua/fidget/notification/window.lua +++ b/lua/fidget/notification/window.lua @@ -566,46 +566,6 @@ function M.show(height, width) return M.get_window(row, col, anchor, relative, width, height) end ----@param buf integer ----@param ns integer ----@param row integer ----@param text any[] ----@param pos string ----@param width integer -local function set_extmark(buf, ns, row, text, pos, width) - if vim.fn.has("nvim-0.11.0") == 1 then - vim.api.nvim_buf_set_extmark(buf, ns, row, 0, { - virt_text = text, - virt_text_pos = pos == "left" and "eol" or "eol_right_align" - }) - else - local left = pos == "left" - -- pre-0.11.0: eol_right_align was only introduced in 0.11.0; - -- without it we need to compute and add the padding ourselves - local len = 0 - local padded = left and {} or { {} } - for _, tok in ipairs(text) do - len = len + vim.fn.strwidth(tok[1]) + vim.fn.count(tok[1], "\t") * math.max(0, M.options.tabstop - 1) - table.insert(padded, tok) - end - local pad_width = math.max(0, width - len) - if pad_width > 0 then - local pad = string.rep(" ", pad_width) - if left then - table.insert(padded, { pad, {} }) - else - padded[1] = { pad, {} } - end - else - padded = text - end - vim.api.nvim_buf_set_extmark(buf, ns, row, 0, { - virt_text = padded, - virt_text_pos = "eol", - }) - end -end - --- Replace the set of lines in the Fidget window, justify them, and apply highlights. --- ---@param message Notification @@ -647,7 +607,12 @@ function M.set_lines(message) table.insert(chunk, t) -- backward compatibility end end - set_extmark(buffer_id, namespace_id, row, chunk, position, message.width) + vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, row, 0, { + virt_text = chunk, + virt_text_pos = position == "left" and "eol" or ( + vim.fn.has("nvim-0.11.0") == 1 and "eol_right_align" or "right_align" + ) + }) row = row + 1 end else @@ -655,7 +620,12 @@ function M.set_lines(message) for _, hdr in ipairs(body.hdr) do table.insert(chunk, hdr) end - set_extmark(buffer_id, namespace_id, row, chunk, message.opts.position, message.width) + vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, row, 0, { + virt_text = chunk, + virt_text_pos = message.opts.position == "left" and "eol" or ( + vim.fn.has("nvim-0.11.0") == 1 and "eol_right_align" or "right_align" + ) + }) row = row + 1 end end From 097fcea093924473375689616aa35bf21485358f Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:22:12 +0200 Subject: [PATCH 24/38] perf: compute nvim version check only once --- lua/fidget/notification/window.lua | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index 09c63f1..5522361 100644 --- a/lua/fidget/notification/window.lua +++ b/lua/fidget/notification/window.lua @@ -566,6 +566,9 @@ function M.show(height, width) return M.get_window(row, col, anchor, relative, width, height) end +---@type boolean +local eol_right = vim.fn.has("nvim-0.11.0") == 1 + --- Replace the set of lines in the Fidget window, justify them, and apply highlights. --- ---@param message Notification @@ -609,9 +612,8 @@ function M.set_lines(message) end vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, row, 0, { virt_text = chunk, - virt_text_pos = position == "left" and "eol" or ( - vim.fn.has("nvim-0.11.0") == 1 and "eol_right_align" or "right_align" - ) + virt_text_pos = position == "left" and "eol" + or eol_right and "eol_right_align" or "right_align" }) row = row + 1 end @@ -622,9 +624,8 @@ function M.set_lines(message) end vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, row, 0, { virt_text = chunk, - virt_text_pos = message.opts.position == "left" and "eol" or ( - vim.fn.has("nvim-0.11.0") == 1 and "eol_right_align" or "right_align" - ) + virt_text_pos = message.opts.position == "left" and "eol" + or eol_right and "eol_right_align" or "right_align" }) row = row + 1 end From 589e7750e1583631f60356faf39059db52f7d5c1 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:22:51 +0200 Subject: [PATCH 25/38] fix: clean up integration loader and unused code Integrate with external plugins when loading window config. --- lua/fidget/notification/view.lua | 24 +++-------------- lua/fidget/notification/window.lua | 43 ++++++++++-------------------- 2 files changed, 18 insertions(+), 49 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index a968183..ba192ca 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -156,25 +156,6 @@ local function normal_hl() return "Normal" -- default end ---- The displayed width of some strings. ---- ---- A simple wrapper around vim.fn.strwidth(), accounting for tab characters ---- manually. ---- ---- We call this instead of vim.fn.strdisplaywidth() because that depends on ---- the state and size of the current window and buffer, which could be ---- anywhere. ----@param ... string ----@return integer len -local function strwidth(...) - local w = 0 - for _, s in ipairs({ ... }) do - w = w + vim.fn.strwidth(s) + - vim.fn.count(s, "\t") * math.max(0, window.options.tabstop - 1) - end - return w -end - ---@return integer len local function line_margin() return 2 * M.options.line_margin @@ -184,7 +165,10 @@ end ---@param ... string ---@return integer len local function line_width(...) - local w = strwidth(...) + local w = 0 + for _, s in pairs({ ... }) do + w = w + vim.fn.strwidth(s) + end return w == 0 and w or w + line_margin() end diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index 5522361..44e8876 100644 --- a/lua/fidget/notification/window.lua +++ b/lua/fidget/notification/window.lua @@ -8,14 +8,13 @@ --- --- Note that for now, it only supports editor-relative floats, though some code --- ported from the legacy version still supports window-relative floats. -local M = {} -local logger = require("fidget.logger") -local need_to_check_integration = false +local M = {} +local logger = require("fidget.logger") ---@options notification.window [[ ---@protected --- Notifications window options -M.options = { +M.options = { --- Base highlight group in the notification window --- --- Used by any Fidget notification text that is not otherwise highlighted, @@ -128,7 +127,16 @@ M.options = { ---@options ]] require("fidget.options").declare(M, "notification.window", M.options, function() - need_to_check_integration = true + -- Integrate with other plugins if needed + vim.iter(require("fidget.integration").options):each(function(file) + local module = require("fidget.integration." .. file) + if module.options.enable == true then + if module.integration_needed() + and not vim.tbl_contains(M.options.avoid, module.filetype) then + table.insert(M.options.avoid, module.filetype) + end + end + end) end) --- The name of the highlight group that Fidget uses to prevent winblend from @@ -211,20 +219,6 @@ local function should_avoid(winnr) return ft == "fidget" or vim.tbl_contains(M.options.avoid, ft) end -local function check_integration() - local xcodebuild = require("fidget.integration.xcodebuild-nvim") - if not vim.tbl_contains(M.options.avoid, xcodebuild.filetype) - and xcodebuild.integration_needed() then - table.insert(M.options.avoid, xcodebuild.filetype) - end - - local nvim_tree = require("fidget.integration.nvim-tree") - if not vim.tbl_contains(M.options.avoid, nvim_tree.filetype) - and nvim_tree.integration_needed() then - table.insert(M.options.avoid, nvim_tree.filetype) - end -end - ---@return integer height of the editor area, excludes statusline and tabline local function get_editor_height() local statusline_height = 0 @@ -246,7 +240,7 @@ end ---@param winnr integer ---@return integer effective_height of the window, excluding winbar local function get_effective_win_height(winnr) - local height = vim.api.nvim_win_get_height(0) + local height = vim.api.nvim_win_get_height(winnr) if vim.fn.exists("+winbar") > 0 and vim.opt.winbar:get() ~= "" then -- When winbar is set, effective win height is reduced by 1 (see :help winbar) return height - 1 @@ -360,11 +354,6 @@ end ---@return ("NE"|"SE") anchor ---@return ("editor"|"win") relative function M.get_window_position() - if need_to_check_integration then - check_integration() - need_to_check_integration = false - end - local row_max, align_bottom, col, row, relative local cursor_row = vim.api.nvim_win_get_cursor(0)[1] - vim.fn.line("w0") @@ -424,10 +413,6 @@ function M.get_buffer() if state.buffer_id == nil or not vim.api.nvim_buf_is_valid(state.buffer_id) then -- Create an unlisted (1st param) scratch (2nd param) buffer state.buffer_id = vim.api.nvim_create_buf(false, true) - vim.api.nvim_set_option_value("filetype", "fidget", { buf = state.buffer_id }) - -- We set this to a known value to ensure we correctly account for the width - -- of tab chars while calling strwidth() in notification.view.strwidth(). - vim.api.nvim_set_option_value("tabstop", M.options.tabstop, { buf = state.buffer_id }) end return state.buffer_id end From 799ec3e8c0f0bb44aa883d9e76dcd2d14b6220d3 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:39:38 +0200 Subject: [PATCH 26/38] perf: minor improvement to window set_lines --- lua/fidget/notification/window.lua | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index 44e8876..b4b5c00 100644 --- a/lua/fidget/notification/window.lua +++ b/lua/fidget/notification/window.lua @@ -567,11 +567,13 @@ function M.set_lines(message) -- Prepare empty lines for extmarks local empty_lines = {} for _ = 1, message.rows, 1 do - table.insert(empty_lines, "") + empty_lines[#empty_lines + 1] = "" end vim.api.nvim_buf_set_lines(buffer_id, 0, -1, false, empty_lines) + empty_lines = nil local row = 0 -- top to bottom + local vtp = eol_right and "eol_right_align" or "right_align" for _, body in ipairs(message.lines) do local chunk = {} @@ -587,30 +589,28 @@ function M.set_lines(message) for _, t in ipairs(token) do if t.text then if prev_ecol < t.scol then - table.insert(chunk, { string.rep(" ", t.scol - prev_ecol), t.hl }) + chunk[#chunk + 1] = { string.rep(" ", t.scol - prev_ecol), t.hl } end - table.insert(chunk, { t.text, t.hl }) + chunk[#chunk + 1] = { t.text, t.hl } prev_ecol = t.ecol else - table.insert(chunk, t) -- backward compatibility + chunk[#chunk + 1] = t -- backward compatibility end end vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, row, 0, { virt_text = chunk, - virt_text_pos = position == "left" and "eol" - or eol_right and "eol_right_align" or "right_align" + virt_text_pos = position == "left" and "eol" or vtp }) row = row + 1 end else ---@cast body NotificationTokens for _, hdr in ipairs(body.hdr) do - table.insert(chunk, hdr) + chunk[#chunk + 1] = hdr end vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, row, 0, { virt_text = chunk, - virt_text_pos = message.opts.position == "left" and "eol" - or eol_right and "eol_right_align" or "right_align" + virt_text_pos = message.opts.position == "left" and "eol" or vtp }) row = row + 1 end From 9e157f7fbcc2ba97be333d93840ee5914c5443eb Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:10:52 +0200 Subject: [PATCH 27/38] refactor: polling timer (start_polling) Poller now allocates one timer and manages it throughout the entire fidget lifecycle. Timer resources are freed after each reset of the notification subsystem state. --- lua/fidget/notification.lua | 5 +- lua/fidget/notification/model.lua | 2 +- lua/fidget/poll.lua | 130 ++++++++++++++++-------------- 3 files changed, 74 insertions(+), 63 deletions(-) diff --git a/lua/fidget/notification.lua b/lua/fidget/notification.lua index bbf4770..0ece797 100644 --- a/lua/fidget/notification.lua +++ b/lua/fidget/notification.lua @@ -327,9 +327,7 @@ end --- ---@return boolean closed_successfully Whether the window closed successfully. function notification.close() - return notification.window.guard(function() - notification.window.close() - end) + return notification.window.guard(notification.window.close) end --- Clear active notifications. @@ -365,6 +363,7 @@ function notification.reset() notification.clear() notification.clear_history() notification.poller:reset_error() -- Clear error if previously encountered one + notification.poller:release() -- Release timer resources end --- The poller for the notification subsystem. diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index 67208df..9ec5714 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -45,7 +45,7 @@ local function del_cached(item, last) cache.group_header[item.config.name] = nil end if last then - if next(cache.render_item) ~= nil then + if cache.render_item and next(cache.render_item) ~= nil then -- At the last notification, free up remaining cached items for k, _ in pairs(cache.render_item) do cache.render_item[k] = nil diff --git a/lua/fidget/poll.lua b/lua/fidget/poll.lua index 07d181f..e7020e4 100644 --- a/lua/fidget/poll.lua +++ b/lua/fidget/poll.lua @@ -33,17 +33,11 @@ function M.unix_time(reltime) return math.floor(unix_time + reltime) end ----luv uv_timer_t handle ----@class uv_timer_t ----@field start fun(self: self, atk: number, delay: number, fn: function) ----@field stop fun(self: self) ----@field close fun(self: self) - --- Encapsulates a function that should be called periodically. ---@class Poller ---@field name string ---@field private poll fun(self: Poller): boolean what to do for polling ----@field private timer uv_timer_t? timer handle when this poller is polling +---@field private timer uv.uv_timer_t? timer handle when this poller is polling ---@field private current_time number time at each poll ---@field private err any? error object possibly encountered while polling --- @@ -63,63 +57,70 @@ Poller.__index = Poller ---@param poll_rate number must be greater than 0 ---@param attack number? must be greater than or equal to 0 function Poller:start_polling(poll_rate, attack) - if self.timer then + if self.timer and self.timer:is_active() then return end - - attack = attack or 15 - - if poll_rate <= 0 then - local msg = string.format("Poller ( %s ) could not start due to non-positive poll_rate: %s", self.name, poll_rate) - logger.error(msg) - error(msg) + if not attack then + attack = 15 end - - if attack < 0 then - local msg = string.format("Poller ( %s ) could not start due to negative poll_rate: %s", self.name, poll_rate) - logger.error(msg) - error(msg) - end - - self.timer = vim.loop.new_timer() - - local start_time - - if logger.at_level(vim.log.levels.INFO) then - start_time = M.get_time() - logger.info("Poller (", self.name, ") starting at", string.format("%.3fs", start_time)) + if poll_rate <= 0 or attack < 0 then + local err = string.format( + "Poller ( %s ) could not start due to %s: %d", + self.name, + poll_rate <= 0 and "non-positive poll_rate" or "negative attack", + poll_rate <= 0 and poll_rate or attack + ) + logger.error(err) + return end - - self.timer:start(attack, math.ceil(1000 / poll_rate), vim.schedule_wrap(function() - if not self.timer or self.err ~= nil then - return + local start_t = 0 + local interval = math.ceil(1000 / poll_rate) + local notice = logger.at_level(vim.log.levels.INFO) + local time = M.get_time + + if not self.timer then + local err + self.timer, err = vim.loop.new_timer() + if not self.timer then + error(err) -- raise this end - - self.current_time = M.get_time() - - local ok, cont = pcall(self.poll, self) - - if not ok or not cont then - self.timer:stop() - self.timer:close() - self.timer = nil - - if logger.at_level(vim.log.levels.INFO) then - -- NOTE: the timing info logged here is not tied to self.current_time - local end_time = M.get_time() - local duration = end_time - (start_time or math.huge) - local message = string.format("stopping at %.3fs (duration: %.3fs)", end_time, duration) - local reason = ok and "due to completion" or string.format("due to error: %s", tostring(cont)) - logger.info("Poller (", self.name, ")", message, reason) - end - - if not ok then - -- Save error object and propagate it - self.err = cont - error(cont) + if not self.callback then + self.callback = function() + if not self.timer or self.err ~= nil then + return + end + self.current_time = time() + + -- logger.debug(collectgarbage("count")) + + local ok, res = pcall(self.poll, self) + if not ok or not res then + self.timer:stop() + + if notice then + local end_t = time() + -- NOTE: the timing info logged here is not tied to self.current_time + logger.info(string.format( + "Poller ( %s ) stopping at %.3fs (duration: %.3fs) due to %s", + self.name, + end_t, + end_t - (start_t or math.huge), + ok and "completion" or "error" + )) + end + if not ok then + self.err = res + logger.error(res) + end + end end end - end)) + end + if notice then + start_t = time() + logger.info(string.format("Poller ( %s ) starting at %.3f", self.name, start_t)) + end + self.timer:start(attack, interval, vim.schedule_wrap(self.callback)) end --- Call the poll() function once, if the poller isn't already running. @@ -152,9 +153,9 @@ end --- Whether a poller is actively polling. --- ----@return boolean is_polling +---@return boolean? is_polling function Poller:is_polling() - return self.timer ~= nil + return self.timer and self.timer:is_active() end --- Query poller for potential encountered error. @@ -164,6 +165,17 @@ function Poller:has_error() return self.err end +--- Release timer resources +function Poller:release() + if self.timer then + if self.timer:is_active() then + self.timer:stop() + end + self.timer:close() + self.timer = nil + end +end + --- Forget about error object so that poller can start polling again. function Poller:reset_error() self.err = nil From 3b95c4685cb56131b97c7ff06d8a982f2590b546 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:30:55 +0200 Subject: [PATCH 28/38] perf(poller): uses libuv to handle time based events Polls frame time using high-resolution timestamps (in nanoseconds). Converts "item.last_updated" to unix time before storing item to history buffer. --- lua/fidget/notification/model.lua | 10 ++--- lua/fidget/poll.lua | 62 ++++++++++++------------------- 2 files changed, 29 insertions(+), 43 deletions(-) diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index 9ec5714..26ca6b0 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -121,7 +121,7 @@ local function add_removed(state, now, group, item) -- Skip duplicates unless we have no items deduplication if group.config.update_hook and #state.removed > 0 then if state.removed[state.removed_first - 1].content_key - and state.removed[state.removed_first - 1].content_key == item.content_key + and state.removed[state.removed_first - 1].content_key == item.content_key then return end @@ -138,7 +138,7 @@ local function add_removed(state, now, group, item) end ---@cast item HistoryItem - item.last_updated = poll.unix_time(now) + item.last_updated = poll.unix_time() item.removed = true item.group_key = group.key item.group_name = group_name @@ -157,7 +157,7 @@ end local function item_to_history(item, extra) ---@type HistoryItem item = vim.tbl_extend("force", item, extra) - item.last_updated = poll.unix_time(item.last_updated) + item.last_updated = poll.unix_time() return item end @@ -248,9 +248,9 @@ end ---@return number expiry_time local function compute_expiry(now, ttl, default_ttl) if not ttl or ttl == 0 then - return now + (default_ttl or 3) + return now + ((default_ttl or 3) * 10 ^ 9) else - return now + ttl + return now + (ttl * 10 ^ 9) end end diff --git a/lua/fidget/poll.lua b/lua/fidget/poll.lua index e7020e4..d55ab48 100644 --- a/lua/fidget/poll.lua +++ b/lua/fidget/poll.lua @@ -1,24 +1,11 @@ local M = {} local logger = require("fidget.logger") ---- Arbitrary point in time that timestamps are computed relative to. ---- ---- Units are in seconds. `unix_time` is relative to Jan 1 1970, while ---- `origin_time` is relative to some arbitrary system-specific time. ---- ---- This module captures both at the time so that we can freely convert between ---- the two. By default, we use `origin_time` / `reltime()` since these offer ---- higher precision, but then we use `unix_time` to normalize it to something ---- human-readable. ---- ----@type number, number -local unix_time, origin_time = vim.fn.localtime(), vim.fn.reltime() - ---- Obtain the seconds passed since this module was initialized. +--- Returns the current high-resolution timestamp (in nanoseconds). --- ---@return number function M.get_time() - return vim.fn.reltimefloat(vim.fn.reltime(origin_time)) + return vim.uv.hrtime() end --- Obtain the (whole) seconds passed since Jan 1, 1970. @@ -26,11 +13,9 @@ end --- In particular, the result from this function is suitable for consumption by --- |strftime()|. --- ----@param reltime number|nil ----@return number localtime -function M.unix_time(reltime) - reltime = reltime or M.get_time() - return math.floor(unix_time + reltime) +---@return integer localtime +function M.unix_time() + return vim.uv.clock_gettime("realtime").sec end --- Encapsulates a function that should be called periodically. @@ -38,7 +23,8 @@ end ---@field name string ---@field private poll fun(self: Poller): boolean what to do for polling ---@field private timer uv.uv_timer_t? timer handle when this poller is polling ----@field private current_time number time at each poll +---@field private start_t number start time of the poller +---@field private current_t number time at each poll ---@field private err any? error object possibly encountered while polling --- --- Note that when the Poller:poll() method returns true, the poller should @@ -57,7 +43,7 @@ Poller.__index = Poller ---@param poll_rate number must be greater than 0 ---@param attack number? must be greater than or equal to 0 function Poller:start_polling(poll_rate, attack) - if self.timer and self.timer:is_active() then + if self.timer and self.timer:is_active() or self.err ~= nil then return end if not attack then @@ -73,14 +59,13 @@ function Poller:start_polling(poll_rate, attack) logger.error(err) return end - local start_t = 0 local interval = math.ceil(1000 / poll_rate) local notice = logger.at_level(vim.log.levels.INFO) local time = M.get_time if not self.timer then local err - self.timer, err = vim.loop.new_timer() + self.timer, err = vim.uv.new_timer() if not self.timer then error(err) -- raise this end @@ -89,7 +74,7 @@ function Poller:start_polling(poll_rate, attack) if not self.timer or self.err ~= nil then return end - self.current_time = time() + self.current_t = time() -- logger.debug(collectgarbage("count")) @@ -98,13 +83,13 @@ function Poller:start_polling(poll_rate, attack) self.timer:stop() if notice then - local end_t = time() + local end_t = time() / 1e9 -- NOTE: the timing info logged here is not tied to self.current_time logger.info(string.format( "Poller ( %s ) stopping at %.3fs (duration: %.3fs) due to %s", self.name, end_t, - end_t - (start_t or math.huge), + end_t - self.start_t, ok and "completion" or "error" )) end @@ -117,10 +102,10 @@ function Poller:start_polling(poll_rate, attack) end end if notice then - start_t = time() - logger.info(string.format("Poller ( %s ) starting at %.3f", self.name, start_t)) + self.start_t = time() / 1e9 + logger.info(string.format("Poller ( %s ) starting at %.3f", self.name, self.start_t)) end - self.timer:start(attack, interval, vim.schedule_wrap(self.callback)) + self.timer:start(attack, interval, function() vim.schedule(self.callback) end) end --- Call the poll() function once, if the poller isn't already running. @@ -130,9 +115,9 @@ function Poller:poll_once() end vim.schedule(function() - self.current_time = M.get_time() + self.current_t = M.get_time() if logger.at_level(vim.log.levels.INFO) then - logger.info("Poller (", self.name, ") polling once at", string.format("%.3fs", self.current_time)) + logger.info("Poller (", self.name, ") polling once at", string.format("%.3fs", self.current_t)) end local ok, err = pcall(self.poll, self) if not ok then @@ -148,7 +133,7 @@ end --- ---@return number function Poller:now() - return self.current_time + return self.current_t end --- Whether a poller is actively polling. @@ -198,11 +183,12 @@ function M.Poller(opts) ---@type Poller local poller = { - name = name, - poll = opts.poll or function() return false end, - timer = nil, - current_time = 0, - err = nil, + name = name, + poll = opts.poll or function() return false end, + timer = nil, + start_t = 0, -- log metric + current_t = 0, -- frame time + err = nil, } return setmetatable(poller, Poller) end From b6e61c09c688f5f1e0e0ba8e9f937d413ffd55dd Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:48:05 +0200 Subject: [PATCH 29/38] perf(model.tick): reduces memory allocation --- lua/fidget/notification/model.lua | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index 26ca6b0..e0453fc 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -399,28 +399,24 @@ end ---@param now number timestamp of current frame. ---@param state State function M.tick(now, state) - local new_groups = {} - for _, group in ipairs(state.groups) do - local new_items = {} + for i = #state.groups, 1, -1 do + local group = state.groups[i] -- Dereference unused items on the last notification if #group.items == 0 then del_cached(group, true) - end - for _, item in ipairs(group.items) do - if item.expires_at > now then - table.insert(new_items, item) - else - del_cached(item) - add_removed(state, now, group, item) - end - end - if #group.items > 0 then - group.items = new_items - table.insert(new_groups, group) + table.remove(state.groups, i) else + for j = #group.items, 1, -1 do + local item = group.items[j] + + if item.expires_at <= now then + del_cached(item) + add_removed(state, now, group, item) + table.remove(group.items, j) + end + end end end - state.groups = new_groups end --- Generate a notifications history according to the provided filter. From a066a1396d52edfce7d90cb9f511206d0a315c41 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:32:35 +0200 Subject: [PATCH 30/38] perf(guard): reduces memory allocation --- lua/fidget/notification.lua | 10 ++++++---- lua/fidget/notification/window.lua | 13 +++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/lua/fidget/notification.lua b/lua/fidget/notification.lua index 0ece797..e231f44 100644 --- a/lua/fidget/notification.lua +++ b/lua/fidget/notification.lua @@ -363,9 +363,12 @@ function notification.reset() notification.clear() notification.clear_history() notification.poller:reset_error() -- Clear error if previously encountered one - notification.poller:release() -- Release timer resources + notification.poller:release() -- Release timer resources end +---@private +local _guard = notification.window.guard + --- The poller for the notification subsystem. ---@protected notification.poller = poll.Poller { @@ -380,9 +383,8 @@ notification.poller = poll.Poller { return true end - notification.window.guard(function() - notification.window.set_lines(message) - end) + _guard(notification.window.set_lines, message) + return true else if state.view_suppressed then diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index b4b5c00..0fd8f4a 100644 --- a/lua/fidget/notification/window.lua +++ b/lua/fidget/notification/window.lua @@ -180,19 +180,20 @@ local state = { --- (Thanks @wookayin and @0xAdk!) --- ---@param callable fun() +---@param args any? ---@return boolean suppressed_error -function M.guard(callable) +function M.guard(callable, args) + local ok, err = pcall(callable, args) + if ok then + return true + end + local whitelist = { "E11: Invalid in command%-line window", "E523: Not allowed here", "E565: Not allowed to change", } - local ok, err = pcall(callable) - if ok then - return true - end - if type(err) ~= "string" then -- Don't know how to deal with this kind of error object error(err) From 361b6074e3941594bd6a4c6eefb2ec5f09ed9739 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:44:44 +0200 Subject: [PATCH 31/38] fix: direct cache access instead of function --- lua/fidget/notification/model.lua | 4 +--- lua/fidget/notification/view.lua | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index e0453fc..8ae2388 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -30,9 +30,7 @@ local poll = require("fidget.poll") ---@field render_item table ---@field render_width integer local cache = {} - ----@return Cache -function M.cache() return cache end +M.cache = cache --- Deletes objects from the cache. --- diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index ba192ca..6e5e435 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -6,7 +6,7 @@ local window = require("fidget.notification.window") local logger = require("fidget.logger") ---@type Cache -local cache = require("fidget.notification.model").cache() +local cache = require("fidget.notification.model").cache ---@type table local tsquery = {} From 51196aacf8ee331b5deaa2da5e01f16baf3bd375 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:13:17 +0200 Subject: [PATCH 32/38] perf: cache editor columns (updated on resize) Removes repeated allocations caused by frequent accesses to editor column value. Now updates cached columns only during window resize events, this reduces memory usage inside the rendering loop (see view.window_max) --- lua/fidget/notification/view.lua | 2 +- lua/fidget/notification/window.lua | 28 ++++++++++++++++++++-------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 6e5e435..e252184 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -176,7 +176,7 @@ end local function window_max() local pad = line_margin() + 4 local win = window.max_width() - pad - local ed = vim.opt.columns:get() - pad + local ed = window.get_editor_width() - pad -- We ditch math.huge constant here because we need a limit to split lines if win <= 0 or ed < win then return ed diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index 0fd8f4a..b648d6e 100644 --- a/lua/fidget/notification/window.lua +++ b/lua/fidget/notification/window.lua @@ -233,11 +233,6 @@ local function get_editor_height() return vim.opt.lines:get() - (statusline_height + vim.opt.cmdheight:get()) end ----@return integer width of the editor area, including signcolumn and foldcolumn -local function get_editor_width() - return vim.opt.columns:get() -end - ---@param winnr integer ---@return integer effective_height of the window, excluding winbar local function get_effective_win_height(winnr) @@ -260,6 +255,23 @@ local function get_tabline_height() end end +---@type integer +local columns = vim.o.columns + +--- Updates columns value when window size changes +vim.api.nvim_create_autocmd('VimResized', { + callback = function() + columns = vim.o.columns + end +}) + +--- Returns the cached width of the editor area, including signcolumns and foldcolumn +--- +---@return integer +function M.get_editor_width() + return columns +end + --- Compute the max width of the notification window. --- ---@return integer @@ -269,7 +281,7 @@ function M.max_width() end if M.options.max_width < 1 then - local width = vim.opt.columns:get() + local width = vim.o.columns return math.ceil(width * M.options.max_width) end @@ -342,7 +354,7 @@ local function search_for_editor_anchor(row_max, align_bottom) -- avoided everything), col will be negative. Set row/col to SE or NE corner -- of the editor so that we have _some_ valid position. if col < 0 then - col = get_editor_width() + col = M.get_editor_width() row = align_bottom and row_max or get_tabline_height() end return row, col @@ -438,7 +450,7 @@ end ---@return number|nil window_id function M.get_window(row, col, anchor, relative, width, height) -- Clamp width and height to dimensions of editor and user specification. - local editor_width, editor_height = get_editor_width(), get_editor_height() + local editor_width, editor_height = M.get_editor_width(), get_editor_height() editor_width = math.max(0, editor_width - 4) -- HACK: guess width of signcolumn etc. if editor_width < 4 or editor_height < 4 then From 3726cee7a85eaa162f5470d08ce5fb012ca4c29a Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sun, 15 Feb 2026 03:09:33 +0200 Subject: [PATCH 33/38] fix: respects window relative positioning --- lua/fidget/notification/view.lua | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index e252184..a2082dd 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -176,10 +176,14 @@ end local function window_max() local pad = line_margin() + 4 local win = window.max_width() - pad - local ed = window.get_editor_width() - pad + local len = window.get_editor_width() - pad + -- Respects window relative positioning + if window.options.relative == "win" then + win = math.min(win, vim.api.nvim_win_get_width(0) - pad) + end -- We ditch math.huge constant here because we need a limit to split lines - if win <= 0 or ed < win then - return ed + if win <= 0 or len < win then + return len end return win end @@ -472,12 +476,13 @@ end --- Render a notification item, containing message and annote. --- ----@param item Item ----@param config Config ----@param count number +---@param item Item +---@param config Config +---@param count number +---@param max_width number ---@return NotificationItems|nil lines ----@return integer width -function M.render_item(item, config, count) +---@return integer width +function M.render_item(item, config, count, max_width) if item.hidden then return nil, 0 end @@ -507,6 +512,7 @@ function M.render_item(item, config, count) end -- We have to keep track of extra lines added in tokens to not cause a desync with hls local extra_line = 0 + local width = 0 ---@type NotificationItem[]|NotificationToken[] local tokens = {} @@ -514,9 +520,6 @@ function M.render_item(item, config, count) local left = item.position and item.position == "left" or M.options.text_position == "left" local sep = config.annote_separator or " " - local width = 0 - local max_width = window_max() - for s in vim.gsplit(msg, "\n", { plain = true, trimempty = true }) do local line = {} local line_ptr = 0 @@ -676,7 +679,7 @@ function M.render(now, groups) local it = cache.render_item[key] if resized or count ~= it[3] then - it[1], it[2] = M.render_item(item, group.config, count) + it[1], it[2] = M.render_item(item, group.config, count, size) it[3] = count end chunks[#chunks + 1] = it[1] @@ -690,7 +693,6 @@ function M.render(now, groups) else start, stop, step = 1, #chunks, 1 end - local rows = 0 local lines = {} for i = start, stop, step do @@ -700,6 +702,10 @@ function M.render(now, groups) lines[#lines + 1] = chunks[i] end end + if window.options.relative == "win" then + -- Ensure text fits within the window width + max_width = max_width > size and size + line_margin() or max_width + end ---@type Notification return { rows = rows, From 8922f846c49a9b014d008206d379a3e40f5dbab3 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:59:42 +0200 Subject: [PATCH 34/38] perf: prevent redundant render operations Add a lock to skip rendering when the state remains constant. A constant state occurs when: - No notification changes (in groups) - Window id and dimensions remain unchanged Now we render only when necessary and reduces the memory usage during frequent polling with small poll rates. --- lua/fidget/notification.lua | 41 ++++++++++++++++++++------- lua/fidget/notification/model.lua | 5 ++++ lua/fidget/notification/view.lua | 45 ++++++++++++++++++++---------- lua/fidget/notification/window.lua | 2 +- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/lua/fidget/notification.lua b/lua/fidget/notification.lua index e231f44..34f7c9a 100644 --- a/lua/fidget/notification.lua +++ b/lua/fidget/notification.lua @@ -1,10 +1,10 @@ ---@mod fidget.notification Notification subsystem -local notification = {} -notification.model = require("fidget.notification.model") -notification.window = require("fidget.notification.window") -notification.view = require("fidget.notification.view") -local poll = require("fidget.poll") -local logger = require("fidget.logger") +local notification = {} +notification.model = require("fidget.notification.model") +notification.window = require("fidget.notification.window") +notification.view = require("fidget.notification.view") +local poll = require("fidget.poll") +local logger = require("fidget.logger") --- Used to determine the identity of notification items and groups. ---@alias Key any @@ -108,15 +108,30 @@ local logger = require("fidget.logger") ---@field include_active boolean|nil Include items that have not been removed (default: true) --- The "model" (abstract state) of notifications. ----@type State -local state = { +---@class State +local state = { groups = {}, + render = false, view_suppressed = false, removed = {}, removed_cap = 128, removed_first = 1, } +--- Must be called when the groups table changes +function state:update() + if not self.render then + self.render = true + end +end + +--- Serves as a lock to prevent unnecessary rendering +function state:wait() + if self.render then + self.render = false + end +end + --- Default notification configuration. --- --- Exposed publicly because it might be useful for users to integrate for when @@ -376,19 +391,25 @@ notification.poller = poll.Poller { poll = function(self) notification.model.tick(self:now(), state) - local message = notification.view.render(self:now(), state.groups) + local message = notification.view.render(self:now(), state) - if #message.lines > 0 then + if message and #message.lines > 0 and state.render then if state.view_suppressed then return true end _guard(notification.window.set_lines, message) + state:wait() + return true else if state.view_suppressed then return false + else + if not state.render then + return true + end end -- If we could not close the window, keep polling, i.e., keep trying to close the window. diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index 8ae2388..1db285c 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -29,6 +29,7 @@ local poll = require("fidget.poll") ---@field group_sep CacheSep ---@field render_item table ---@field render_width integer +---@field window integer local cache = {} M.cache = cache @@ -65,6 +66,7 @@ end --- The abstract state of the notifications subsystem. ---@class State ---@field groups Group[] active notification groups +---@field render boolean whether the notification should be rendered ---@field view_suppressed boolean whether the notification window is suppressed ---@field removed HistoryItem[] ring buffer of removed notifications, kept around for history ---@field removed_cap number capacity of removed ring buffer @@ -331,6 +333,7 @@ function M.update(now, configs, state, msg, level, opts) return (a.config.priority or 50) - (b.config.priority or 50) end) end + state:update() end --- Remove an item from a particular group. @@ -403,6 +406,7 @@ function M.tick(now, state) if #group.items == 0 then del_cached(group, true) table.remove(state.groups, i) + state:update() else for j = #group.items, 1, -1 do local item = group.items[j] @@ -411,6 +415,7 @@ function M.tick(now, state) del_cached(item) add_removed(state, now, group, item) table.remove(group.items, j) + state:update() end end end diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index a2082dd..bc81a6e 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -609,30 +609,47 @@ end --- Render notifications into lines and highlights. --- ---@param now number timestamp of current render frame ----@param groups Group[] ----@return Notification -function M.render(now, groups) +---@param state State +---@return Notification? +function M.render(now, state) + if window.options.relative == "win" then + local id = vim.api.nvim_get_current_win() + -- Force rendering when the window id change + if not cache.window or + cache.window and cache.window ~= id + then + if cache.window then + state:update() + end + cache.window = id + end + end + local size = window_max() + local resized = cache.render_width and cache.render_width ~= size or false + -- Force rendering when the length of the window change + if resized and not state.render then + state:update() + end + if not state.render then + -- Otherwise return and avoid recomputing unnecessarily + return + end + if not cache.render_width or resized then + cache.render_width = size + end + is_multigrid_ui = M.check_multigrid_ui() ---@type NotificationLine[][] local chunks = {} local max_width = 0 + local max = math.max cache.render_item = cache.render_item or {} cache.group_header = cache.group_header or {} cache.group_sep = cache.group_sep or { nil, nil } -- sep, width - local size = window_max() - local max = math.max - - -- Force rendering when the length of the window change - local resized = cache.render_width and cache.render_width ~= size or false - - if not cache.render_width or resized then - cache.render_width = size - end - - for idx, group in ipairs(groups) do + for idx, group in ipairs(state.groups) do if idx ~= 1 then if resized or not cache.group_sep[1] then cache.group_sep[1], cache.group_sep[2] = M.render_group_separator() diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index b648d6e..9828d9d 100644 --- a/lua/fidget/notification/window.lua +++ b/lua/fidget/notification/window.lua @@ -217,7 +217,7 @@ local function should_avoid(winnr) end local bufnr = vim.api.nvim_win_get_buf(winnr) local ft = vim.api.nvim_get_option_value("filetype", { buf = bufnr }) - return ft == "fidget" or vim.tbl_contains(M.options.avoid, ft) + return vim.iter(M.options.avoid):any(function(v) return v == ft end) end ---@return integer height of the editor area, excludes statusline and tabline From b68b28419103164db15dc1c9cf445c134a130add Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:25:24 +0200 Subject: [PATCH 35/38] fix: out of bounds index when checking history duplicate --- lua/fidget/notification/model.lua | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index 1db285c..6472c29 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -120,9 +120,8 @@ local function add_removed(state, now, group, item) if not item.skip_history then -- Skip duplicates unless we have no items deduplication if group.config.update_hook and #state.removed > 0 then - if state.removed[state.removed_first - 1].content_key - and state.removed[state.removed_first - 1].content_key == item.content_key - then + local n = state.removed_first > 1 and state.removed_first - 1 or 1 + if state.removed[n].content_key and state.removed[n].content_key == item.content_key then return end end From 28615aa68acbd86e91ad2709224fe93edeb54736 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:17:18 +0200 Subject: [PATCH 36/38] refactor(model): move cache cleanup and state update calls --- lua/fidget/notification/model.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lua/fidget/notification/model.lua b/lua/fidget/notification/model.lua index 6472c29..c3a2cea 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -117,6 +117,8 @@ end ---@param group Group ---@param item Item local function add_removed(state, now, group, item) + del_cached(item) + state:update() if not item.skip_history then -- Skip duplicates unless we have no items deduplication if group.config.update_hook and #state.removed > 0 then @@ -352,6 +354,7 @@ function M.remove(state, now, group_key, item_key) table.remove(group.items, i) add_removed(state, now, group, item) if #group.items == 0 then + del_cached(group, true) table.remove(state.groups, g) end return true @@ -411,10 +414,8 @@ function M.tick(now, state) local item = group.items[j] if item.expires_at <= now then - del_cached(item) add_removed(state, now, group, item) table.remove(group.items, j) - state:update() end end end From ea646804b997075575a70077cc9ec846878261a9 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:11:10 +0200 Subject: [PATCH 37/38] refactor(poller): handle errors via callback This allows notification or progress to properly close the window and clean up resources whenever an error occurs in the polling logic. To control whether errors should be displayed or logged silently: - notification.options.show_errors = boolean --- lua/fidget/notification.lua | 15 +++++++++++++++ lua/fidget/poll.lua | 31 +++++++++++++++++++++---------- lua/fidget/progress.lua | 9 +++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/lua/fidget/notification.lua b/lua/fidget/notification.lua index 34f7c9a..a9bbc36 100644 --- a/lua/fidget/notification.lua +++ b/lua/fidget/notification.lua @@ -231,6 +231,12 @@ notification.options = { ---@type 0|1|2|3|4|5 filter = vim.log.levels.INFO, + -- Whether to show errors that occur while rendering notifications + -- When false, errors are logged instead of shown to the user + --- + ---@type boolean + show_errors = false, + --- Number of removed messages to retain in history --- --- Set to 0 to keep around history indefinitely (until cleared). @@ -415,6 +421,15 @@ notification.poller = poll.Poller { -- If we could not close the window, keep polling, i.e., keep trying to close the window. return not notification.close() end + end, + raise = function(self) + if self:has_error() then + notification.close() + + self:reset_error() + self:release() + end + return notification.options.show_errors end } diff --git a/lua/fidget/poll.lua b/lua/fidget/poll.lua index d55ab48..cd6f720 100644 --- a/lua/fidget/poll.lua +++ b/lua/fidget/poll.lua @@ -19,13 +19,17 @@ function M.unix_time() end --- Encapsulates a function that should be called periodically. +--- +---@alias Cb fun(self: Poller): boolean +--- ---@class Poller ----@field name string ----@field private poll fun(self: Poller): boolean what to do for polling ----@field private timer uv.uv_timer_t? timer handle when this poller is polling ----@field private start_t number start time of the poller ----@field private current_t number time at each poll ----@field private err any? error object possibly encountered while polling +---@field name string +---@field private poll Cb what to do for polling +---@field private raise Cb handle errors encountered while polling +---@field private timer uv.uv_timer_t? timer handle when this poller is polling +---@field private start_t number start time of the poller +---@field private current_t number time at each poll +---@field private err any? error object possibly encountered while polling --- --- Note that when the Poller:poll() method returns true, the poller should --- call it again, but if it returns anything false-y, the poller will stop. @@ -84,7 +88,7 @@ function Poller:start_polling(poll_rate, attack) if notice then local end_t = time() / 1e9 - -- NOTE: the timing info logged here is not tied to self.current_time + -- This timing info is not tied to frame time (current_t) logger.info(string.format( "Poller ( %s ) stopping at %.3fs (duration: %.3fs) due to %s", self.name, @@ -95,6 +99,9 @@ function Poller:start_polling(poll_rate, attack) end if not ok then self.err = res + if self:raise() then + error(res) + end logger.error(res) end end @@ -117,12 +124,15 @@ function Poller:poll_once() vim.schedule(function() self.current_t = M.get_time() if logger.at_level(vim.log.levels.INFO) then - logger.info("Poller (", self.name, ") polling once at", string.format("%.3fs", self.current_t)) + logger.info(string.format("Poller ( %s ) polling once at %.3fs", self.name, self.current_t / 1e9)) end local ok, err = pcall(self.poll, self) if not ok then self.err = err - error(err) + if self:raise() then + error(err) + end + logger.error(err) end end) end @@ -167,7 +177,7 @@ function Poller:reset_error() end --- Construct a Poller object. ----@param opts { name: string?, poll: fun(self: Poller): boolean }? +---@param opts { name: string?, poll: Cb, raise: Cb }? ---@return Poller poller function M.Poller(opts) opts = opts or {} @@ -185,6 +195,7 @@ function M.Poller(opts) local poller = { name = name, poll = opts.poll or function() return false end, + raise = opts.raise or function() return false end, timer = nil, start_t = 0, -- log metric current_t = 0, -- frame time diff --git a/lua/fidget/progress.lua b/lua/fidget/progress.lua index f1a104b..86340d9 100644 --- a/lua/fidget/progress.lua +++ b/lua/fidget/progress.lua @@ -249,6 +249,15 @@ progress.poller = poll.Poller { end end return true + end, + raise = function(self) + if self:has_error() then + notification.close() + + self:reset_error() + self:release() + end + return notification.options.show_errors end } From 7904ea03bad6f8b9cc04b75bd54bbe9f6338b285 Mon Sep 17 00:00:00 2001 From: nqrk <247219102+nqrk@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:06:49 +0200 Subject: [PATCH 38/38] feat: add reset to fidget command line --- lua/fidget/commands.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lua/fidget/commands.lua b/lua/fidget/commands.lua index 5b4dbea..97a4f78 100644 --- a/lua/fidget/commands.lua +++ b/lua/fidget/commands.lua @@ -460,6 +460,13 @@ SC.subcommands = { { name = "group_key", type = GroupKey, desc = "group to clear" }, }, }, + reset = { + desc = "Reset notification subsystem state", + func = function() + require("fidget.notification").reset() + end, + args = {} + }, history = { desc = "Show notifications history", func = function(args)