Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9de23b6
feat: cache rendered object until ttl expires
nqrk Jan 8, 2026
17389e7
feat: refactor notification view renderer
nqrk Jan 11, 2026
8513789
feat: add treesitter highlight to view renderer
nqrk Jan 17, 2026
9af680d
feat: add renderer highlight config options
nqrk Jan 17, 2026
937cae4
feat: merge markdown with markdown_inline
nqrk Jan 17, 2026
2d5ae61
fix: clean up dead code, lsp warning etc
nqrk Jan 17, 2026
75940b6
fix: prevent extra_line to reset on each iteration
nqrk Jan 18, 2026
c4e6363
fix: use window max_width option before fallback
nqrk Jan 19, 2026
59f59e0
feat: add renderer test file
nqrk Jan 19, 2026
2a6266f
feat: add unicode characters support
nqrk Jan 22, 2026
73582e4
fix: lines overflow when max_width > window width
nqrk Jan 22, 2026
369bec0
feat: tokenize tab as space (see window.tabstop)
nqrk Jan 22, 2026
c8cf929
feat: return unified message object from view render
nqrk Jan 31, 2026
d47755e
feat: add left-aligned text support
nqrk Jan 31, 2026
475fc7a
feat: add per-message text alignment to notify
nqrk Jan 31, 2026
55e04ee
fix: cache window max size instead of editor size
nqrk Jan 31, 2026
e3404d0
feat: add per-message text highlight to notify
nqrk Jan 31, 2026
991af7b
fix: test loop stuck when g.colors_name is nil
nqrk Feb 1, 2026
d167c14
fix: cache rendered object logic
nqrk Feb 6, 2026
835521a
feat: skip duplicates in history if update_hook != false
nqrk Feb 6, 2026
4181aeb
perf: improve highlighter logic
nqrk Feb 7, 2026
2aa7cf5
perf: improve items rendering logic
nqrk Feb 8, 2026
98ebfaf
refactor: buf_set_extmark virtual text position for nvim < 0.11
nqrk Feb 9, 2026
097fcea
perf: compute nvim version check only once
nqrk Feb 9, 2026
589e775
fix: clean up integration loader and unused code
nqrk Feb 10, 2026
799ec3e
perf: minor improvement to window set_lines
nqrk Feb 10, 2026
9e157f7
refactor: polling timer (start_polling)
nqrk Feb 11, 2026
3b95c46
perf(poller): uses libuv to handle time based events
nqrk Feb 12, 2026
b6e61c0
perf(model.tick): reduces memory allocation
nqrk Feb 12, 2026
a066a13
perf(guard): reduces memory allocation
nqrk Feb 13, 2026
361b607
fix: direct cache access instead of function
nqrk Feb 14, 2026
51196aa
perf: cache editor columns (updated on resize)
nqrk Feb 14, 2026
3726cee
fix: respects window relative positioning
nqrk Feb 15, 2026
8922f84
perf: prevent redundant render operations
nqrk Feb 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 58 additions & 27 deletions lua/fidget/notification.lua
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,6 +21,8 @@ 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 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
Expand Down Expand Up @@ -76,6 +78,8 @@ 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 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
Expand All @@ -86,11 +90,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.
---
Expand All @@ -104,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
Expand Down Expand Up @@ -156,8 +175,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.
---
Expand All @@ -181,13 +200,19 @@ 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%s",
item.message,
item.lang and item.lang or "",
item.position and item.position or "",
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).
Expand Down Expand Up @@ -317,9 +342,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.
Expand Down Expand Up @@ -355,30 +378,38 @@ 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

---@private
local _guard = notification.window.guard

--- The poller for the notification subsystem.
---@protected
notification.poller = poll.Poller {
name = "notification",
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)
local message = notification.view.render(self:now(), state)

if #lines > 0 then
if message and #message.lines > 0 and state.render then
if state.view_suppressed then
return true
end

notification.window.guard(function()
notification.window.set_lines(lines, width)
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.
Expand Down Expand Up @@ -459,7 +490,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
108 changes: 87 additions & 21 deletions lua/fidget/notification/model.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,58 @@ local M = {}
local logger = require("fidget.logger")
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<Display, CacheHdr>
---@field group_sep CacheSep
---@field render_item table<Item|any, CacheItem>
---@field render_width integer
---@field window integer
local cache = {}
M.cache = cache

--- Deletes objects from the cache.
---
---@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 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
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

--- 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
Expand All @@ -32,7 +81,7 @@ local poll = require("fidget.poll")
---@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.
Expand All @@ -49,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,
Expand All @@ -69,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)
Expand All @@ -80,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
Expand All @@ -99,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

Expand Down Expand Up @@ -190,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

Expand Down Expand Up @@ -237,6 +295,8 @@ 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,
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),
Expand All @@ -254,6 +314,8 @@ 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.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
Expand All @@ -271,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.
Expand Down Expand Up @@ -337,23 +400,26 @@ 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 _, item in ipairs(group.items) do
if item.expires_at > now then
table.insert(new_items, item)
else
add_removed(state, now, group, item)
end
end
if #group.items > 0 then
group.items = new_items
table.insert(new_groups, group)
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)
table.remove(state.groups, i)
state:update()
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)
state:update()
end
end
end
end
state.groups = new_groups
end

--- Generate a notifications history according to the provided filter.
Expand Down
Loading