Skip to content

refactor: rework notification renderer and poller#302

Open
nqrk wants to merge 38 commits intoj-hui:mainfrom
nqrk:feat-renderv2
Open

refactor: rework notification renderer and poller#302
nqrk wants to merge 38 commits intoj-hui:mainfrom
nqrk:feat-renderv2

Conversation

@nqrk
Copy link

@nqrk nqrk commented Jan 19, 2026

This pull request primarily refactors the rendering pipeline and includes changes to the notification poller.
Main focus is to bring up new features while trying to reduces memory usage.

I'm not sure if I should merge the recent fixes to Fidget here, from what I see this branch should not have conflict in case the fixes are first merged to the main branch and then this rework is applied on top of it. But I will monitor if any new changes break this PR.

Noticeable changes for users

  • Notification highlights via treesitter. (e.g, "markdown")
  • Possible to change default text alignment inside the notification.
  • Use reset command to clear notification state and free resources.
  • A cleaner history list where duplicates are removed unless update_hook is set to false.
  • Better performance, rendering notifications consumes less memory.
notification = {
    --- 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,

    view = {
        --- 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,

        --- Position of the text inside the window
        ---@type "left"|"right"
        text_position = "right",
    }
}
  • Introduces position and lang override as notification options.
vim.notify(text, loglevel, { position = "left", lang = "lua" })

How to test?

Load this PR either manually or by using Lazy

{
    "nqrk/fidget.nvim",
    branch = "feat-renderv2",
    opts = {
        notification = {
            override_vim_notify = true
        }
    }
}
:lua vim.notify("A **notification** message `with` ~lua~ markdown!")

If you find any issues, please report them here. It would be helpful if you could include relevant
context about the issue or provide a way to reproduce it using a test case. (see notify.lua)


Changes in the background

Cache

Fidget now caches the group separator, header and messages to reduce rendering work.

  • The renderer re-uses previous tokens when:
    • Window's width has not changed.
    • Same item count, icon or group name is present.
    • Notification has not reached its ttl expiration.

When model.tick() runs and a notification item reaches ttl, the item is removed from the cache.
When the notification closes, all items (including headers) are cleared from the cache except for
the default group header and separator (since they never change).

Editor columns is also cached, and the value is updated only during window resize events.

This eliminates repeated allocations in the rendering loop caused by frequent access to the editor
columns value. See view.window_max().

Treesitter queries to the highlights.scm file are also cached per language.

Note that Lua keeps a strong reference to the cache until client exits.
I do not expect it to grow that much, but if it becomes an issue we could dereference it when the
window closes.

  • Direct access to the cache:
    • :lua = require("fidget.notification.model").cache
{
  group_header = {
    Notifications = { {
        hdr = { <1>{ " ", { "FidgetNoBlend" } }, { "Notifications", { "FidgetNoBlend", "Title" } }, { " ", { "FidgetNoBlend" } }, { "❰❰", { "FidgetNoBlend", "Special" } }, <table 1> }
      }, 18, "❰❰" }
  },
  group_sep = {},
  render_item = {},
  render_width = 306, -- editor width
  window = 1000 -- window id
}

When render_item is used to display a notification.

...
render_item = {
-- content_key = message-lang-position-annote|\0
["foo--\0"] = { {
    line = { { <2>{ " ", { "FidgetNoBlend" } }, {
          ecol = 3,
          hl = { "FidgetNoBlend", "Comment" },
          scol = 0,
          text = "foo"
        }, <table 2> } },
    opts = {} -- rendering option
  }, 5, 1 } -- width (+padding), count
}
...

History

If the update_hook function (used for item deduplication) is not set to false, a removed duplicate item will not be added to the history buffer.

This means that a notification rendered via render_message() with a count greater than 1 will appear on‑screen, but only one item will be added to the history.


Tokenization

The tokenization of message has changed: lines are now rendered using an array of words composed of
only non-space characters. Spacing is added during rendering by calculating the start and end
positions of each word.

  • Lines that exceed max_width are reflowed in a word-break fashion.
  • Annotations default to the old "annote" behavior. (see view.options.align)
  • Tabs are translated to spaces following the window.options.tabstop value.
  • Message width limit also respects window relative positioning. (see window.options.relative)
NotificationItem {
    scol: integer -- start column
    ecol: integer -- end column
    text: string  -- a character or word
    hl: string[]  -- highlights group
}

Highlights

Notifications can now be highlighted via Treesitter.

  • The default language used to highlight messages is markdown_inline.

Note that if the language is set to "markdown" as a global option, the highlight method will run
twice. (For both "markdown" and "markdown_inline")

For certain types of languages such as Markdown, it is aesthetically pleasing to remove tags.

The renderer can do this by default when the tags highlight group is set to @conceal and the
global config option view.hide_conceal is set to true.

The highlighter creates the same structure used by the tokenizer: including start and end positions
of the highlight ranges.

{
    srow = integer -- starting row (to sync with tokens)
    scol = integer -- starting column
    ecol = integer -- end column
    text = string  -- character or word
    hl = integer   -- highlight group (from hlID)
}

Renderer have to synchronizes those values with the current tokenized message, taking into account
extra lines added when a line overflows and has to be reflowed.

Unicode character support was re-introduced and can also be highlighted.


Renderer

The render() method inside view.lua returns a unified message object to the poller callback.

{
    rows = integer  -- total amount of lines in the message
    lines = tokens  -- see render_items() bellow
    width = integer -- width of the longest line inside the window
    opts = {
        upwards = boolean -- config: display from bottom to top
        position = string -- config: default virtual text position to use
    }
}

Returned by render_items() is a tokens.

{
    line = NotificationItem[] -- the message tokenized including highlights
    opts = {
        position = string -- per-line positioning
        ...
    }
}

For now only the position option is used to carry per-item option to the extmark renderer.

State lock

A state lock was added to skip rendering when the state remains constant.

  • Condition:
    • No change to the notification groups.
    • Window ID and dimensions remain unchanged.

The lock works as follows:

  • vim.notify() is called and message is passed to notify().
  • The state is updated: items are created and rendering lock is set to true.
  • Item renderer generates tokens and highlights from the message.
  • A message object is created and returned to the poller callback.
  • Inside the poller callback, window.set_lines is called if rendering lock is true.
  • Once the window and message are drawn, rendering lock is set to false.
  • Enters a "sleep" state after a single render.
  • (Polling continues...)

A state change will set the rendering lock to true (in an "update" status) and the
notification is rendered again on the next frame. (see poll_rate)

  • State change condition:
    • An item is removed from a group due to ttl expiration or client action.
    • Window receives a resize event.

To prevent artifacts, the window is closed in case of errors. (see Poller bellow)

Fidget now only renders when necessary. This leads to significant drop in memory usage during
frequent polling since we also reduce the number of allocations required for calling nvim internal
api.


Poller

The poller now allocates one timer and manages it for the entire lifecycle instead of stopping,
closing and dereferencing a timer at each poll. The resources are freed after a reset of the
notification subsystem state.

In case of errors:

  • Timer resources are automatically freed.
  • Notification window is closed.
  • Error is raised or silently logged. (see options.show_errors)
Frame time

Now uses libuv to handle time-based events, frame time is calculated from the difference between
high-resolution timestamp values in nanoseconds. (see uv.hrtime)

We completely ditch native api like relatime[float]() which constantly allocates.

When an item is added to the history buffer, item.last_updated is cast to a unix-time format (see
uv.clock_gettime)


Perf. / misc.

  • Various table-access optimizations inside loops to reduce rehashing.
  • Small refactoring tricks to improve memory allocations.
  • Dropped some vim.tbl_ methods in favor of vim.iter.
  • Neovim version is now computed only once to reduce overhead (since it never changes).
  • By design, tokens passed to window.set_lines() allow us to drop the legacy padding code that was
    needed to support nvim < 0.11. So instead of computing the padding ourselves we can use the native
    virt_text_pos = right_align.
  • The window.guard method can now receive arguments to pass to the pcall function, we skip the
    need for closures to call it. Also the list of errors as a string table is allocated only if pcall
    returns an error.
  • Integrated plugins check is no longer called from the rendering loop at get_window_position().
    Instead, when loading the window module configuration, the integration module is iterated over to check for options.enable booleans.
  • A test file called notify.lua was added to the tests folder. It shows multiple notification
    scenarios through vim.notify with different configuration options. It is not an automated
    headless test but a visual guideline for writing or testing the rendering code. Feel free to
    create a better one with Plenary etc, i don't have it in me to do it.

nqrk added 9 commits January 19, 2026 03:41
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)
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.
Related to j-hui#159
Implements tree-sitter highlight to rendered tokens.
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
The highlight function can now reuse previously generated hl tokens.
@nqrk nqrk changed the title refactor(renderer): rework view renderer (+ cache, highlights) refactor(renderer): rework view renderer (add cache, highlights) Jan 19, 2026
@nqrk nqrk marked this pull request as draft January 20, 2026 21:54
nqrk added 15 commits January 31, 2026 04:48
Reorganise view render to return a "Notification".
This is the first step to introduce per-message options via notify.
Related to j-hui#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".
Related to j-hui#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.
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.
Users can now let each message be independently highlighted with different language:
    - `vim.notify([[block of code]], nil, { lang = "lua" })`
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.
Cache "highlights" queries and reuse them when parsing the same language.
+ minor improvement in the duplicate items detection loop.
Uses nvim "right_align" text position instead of computing padding ourselves.
Integrate with external plugins when loading window config.
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.
nqrk added 11 commits February 12, 2026 22:32
Polls frame time using high-resolution timestamps (in nanoseconds).
Converts "item.last_updated" to unix time before storing item to history buffer.
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)
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.
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
@nqrk nqrk changed the title refactor(renderer): rework view renderer (add cache, highlights) refactor: rework notification renderer and poller Feb 21, 2026
@nqrk nqrk marked this pull request as ready for review February 21, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant