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) diff --git a/lua/fidget/notification.lua b/lua/fidget/notification.lua index abb1268..a9bbc36 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 @@ -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 @@ -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 @@ -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. --- @@ -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 @@ -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. --- @@ -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). @@ -206,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). @@ -317,9 +348,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. @@ -355,8 +384,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 end +---@private +local _guard = notification.window.guard + --- The poller for the notification subsystem. ---@protected notification.poller = poll.Poller { @@ -364,26 +397,39 @@ 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) + 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. 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 } @@ -459,7 +505,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/model.lua b/lua/fidget/notification/model.lua index 19fa42e..c3a2cea 100644 --- a/lua/fidget/notification/model.lua +++ b/lua/fidget/notification/model.lua @@ -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 +---@field group_sep CacheSep +---@field render_item table +---@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 @@ -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. @@ -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, @@ -68,7 +117,17 @@ 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 + 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 + local group_name = group.config.name if type(group_name) == "function" then group_name = group_name(now, group.items) @@ -80,7 +139,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 @@ -99,7 +158,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 @@ -190,9 +249,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 @@ -237,6 +296,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), @@ -254,6 +315,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 @@ -271,6 +334,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. @@ -290,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 @@ -337,23 +402,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 _, 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 + 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. diff --git a/lua/fidget/notification/view.lua b/lua/fidget/notification/view.lua index 228ffb1..bc81a6e 100644 --- a/lua/fidget/notification/view.lua +++ b/lua/fidget/notification/view.lua @@ -1,12 +1,44 @@ --- 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") +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 +---@field rows integer total amount of lines +---@field width integer width of longest line + +---@class NotificationOpts +---@field upwards boolean display from bottom to top +---@field position string virtual text position --- 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[], opts: NotificationItemOpts } + +---@class NotificationItem +---@field ecol integer +---@field scol integer +---@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[]} @@ -23,6 +55,21 @@ 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 + highlight = "markdown_inline", + + --- Hide markdown tags with the "conceal" highlight name + --- + ---@type boolean + hide_conceal = true, + --- Indent messages longer than a single line --- --- Example: ~ @@ -39,32 +86,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 @@ -127,36 +148,12 @@ function M.check_multigrid_ui() return false 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 ---- 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) +---@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 w + return "Normal" -- default end ---@return integer len @@ -168,10 +165,82 @@ 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 +---@return number +local function window_max() + local pad = line_margin() + 4 + local win = window.max_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 len < win then + return len + 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. +--- Ignores consecutives whitespace. +--- scol ecol word +---@alias Token { [1]: integer, [2]: integer, [3]: string } +--- +---@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 = strchars(source) + + while pos < len do + ---@type string + local char = strcharpart(source, pos, 1) + + if char:match("%w") then + local ptr = pos + local word = { char } + + while ptr + 1 < len do + local c = strcharpart(source, ptr + 1, 1) + if not c:match("%w") then + break + end + word[#word + 1] = c + ptr = ptr + 1 + end + 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 + res[#res + 1] = { pos + tab, pos + tab, char } + end + end + pos = pos + 1 + end + 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 @@ -182,7 +251,9 @@ local function Token(text, ...) return { text, { window.no_blend_hl, ... } } end ----@param ... NotificationToken +--- Pack a notification token inside margin and returns a notification line. +--- +---@param ... NotificationToken|NotificationItem ---@return NotificationLine local function Line(...) if select("#", ...) == 0 then @@ -195,6 +266,141 @@ 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 +---@param left boolean +---@return table line +---@return integer width +local function Annote(line, width, annote, sep, first, left) + if not annote then + return line, width + end + if first then + annote[1] = left and annote[1] .. sep or sep .. annote[1] + if left then + line = { annote, unpack(line) } + else + line[#line + 1] = 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)) + if left then + line = { pad, unpack(line) } + else + line[#line + 1] = pad + end + width = width + len + end + end + return line, width +end + +--- Returns the Treesitter highlight groups for a given source and language. +--- +---@param source string +---@param lang string +---@param prev_hls table|nil +---@return table|nil hls +local function Highlight(source, lang, prev_hls) + local ok, parser = pcall(function() + return vim.treesitter.get_string_parser(source, lang) + end) + if not ok then + logger.warn(parser) + return + end + 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 + if prev_hls then + hls = prev_hls + end + local line = {} + local prev_line = 0 + local prev_text, prev_range + + 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 + end + local name = query.captures[id] + if name == "spell" or name == "nospell" then + goto continue -- ignores spellcheck + end + + 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 + hl = vim.fn.hlID(normal_hl()) -- fallback + end + end + + local srow, scol, _, ecol = node:range() + if prev_line ~= srow then + hls[#hls + 1] = line -- push to a new line + prev_line = srow + line = {} + 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 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 + hls[#hls + 1] = line + end + return hls +end + ---@return NotificationLine[]|nil lines ---@return integer width function M.render_group_separator() @@ -202,8 +408,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) - -- TODO: cache the return value, this never changes + 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. @@ -233,19 +438,19 @@ 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 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 { 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 @@ -263,7 +468,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 @@ -271,12 +476,13 @@ end --- Render a notification item, containing message and annote. --- ----@param item Item ----@param config Config ----@param count number ----@return NotificationLine[]|nil lines ----@return integer max_width -function M.render_item(item, config, count) +---@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, max_width) if item.hidden then return nil, 0 end @@ -287,167 +493,214 @@ 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 = "…" - 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 + local hl = { nil, nil } + if not is_multigrid_ui then + hl[1] = window.no_blend_hl 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 + sepeparator - 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]))) + hl[#hl + 1] = normal_hl() + + local hls + 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 lang == "markdown" then + hls = Highlight(msg, "markdown_inline", hls) end - else - table.insert(lines, Line(line)) - item_width = math.max(item_width, line_width(line[1])) 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 width = 0 + + ---@type NotificationItem[]|NotificationToken[] + local tokens = {} + local annote = item.annote and Token(item.annote, item.style) + local left = item.position and item.position == "left" or M.options.text_position == "left" + local sep = config.annote_separator or " " + + 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 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 - 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 + -- 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, left) end - - 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 + 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, + ecol = token[2] - next_start + 1, + text = token[3], + hl = hl + } + 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 + -- paint the whole line + if ts.scol == 0 and ts.ecol == 0 + or + 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 - strlen + word.text = "" + end + end + word.hl = iter(word.hl):map(function(value) + if value ~= window.no_blend_hl then value = ts.hl end + return value + end):totable() + end end end end - - 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 - end - insert(Token(line)) - split_begin = split_begin + split_len - lwidth = lwidth - split_len 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 + strlen + 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, left) end - else - return lines, item_width + 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 + tokens = { Line(annote) } end + 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. --- ---@param now number timestamp of current render frame ----@param groups Group[] ----@return NotificationLine[] lines ----@return integer width -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 - for idx, group in ipairs(groups) do + for idx, group in ipairs(state.groups) do if idx ~= 1 then - local sep, sep_width = M.render_group_separator() - 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 - local hdr, hdr_width = M.render_group_header(now, group) - 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 + if not cache.group_header[group.config.name] then + cache.group_header[group.config.name] = { nil, nil, nil } -- hdr, width, icon + end + 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 = M.render_item(item, group.config, counts[item.content_key or item]) - if it then - table.insert(chunks, it) - max_width = math.max(max_width, it_width) + if not cache.render_item[key] then + cache.render_item[key] = { nil, nil, nil } -- it, width, count end + local it = cache.render_item[key] + + if resized or count ~= it[3] then + it[1], it[2] = M.render_item(item, group.config, count, size) + it[3] = count + end + chunks[#chunks + 1] = it[1] + max_width = max(max_width, it[2]) end end @@ -457,14 +710,30 @@ function M.render(now, groups) else 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) + lines[#lines + 1] = chunks[i] end end - return lines, max_width + 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, + lines = lines, + width = max_width, + ---@type NotificationOpts + opts = { + upwards = M.options.stack_upwards, + position = M.options.text_position, + } + } end --- Display notification items in Neovim messages. diff --git a/lua/fidget/notification/window.lua b/lua/fidget/notification/window.lua index 5fab6a3..9828d9d 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 @@ -172,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) @@ -208,21 +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) -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 + 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 @@ -238,15 +233,10 @@ 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) - 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 @@ -265,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 @@ -274,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 @@ -347,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 @@ -360,11 +367,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 +426,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 @@ -452,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 @@ -566,13 +564,13 @@ 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. ---- +---@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 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,37 +578,57 @@ 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 + empty_lines[#empty_lines + 1] = "" + end vim.api.nvim_buf_set_lines(buffer_id, 0, -1, false, empty_lines) - - for iline, line in ipairs(lines) do - 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, - 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) - table.insert(padded, tok) + 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 = {} + + ---@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 + + for _, t in ipairs(token) do + if t.text then + if prev_ecol < t.scol then + chunk[#chunk + 1] = { string.rep(" ", t.scol - prev_ecol), t.hl } + end + chunk[#chunk + 1] = { t.text, t.hl } + prev_ecol = t.ecol + else + 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 vtp + }) + row = row + 1 end - local pad_width = math.max(0, width - len) - if pad_width > 0 then - padded[1] = { string.rep(" ", pad_width), {} } - else - padded = line + else + ---@cast body NotificationTokens + for _, hdr in ipairs(body.hdr) do + chunk[#chunk + 1] = hdr end - vim.api.nvim_buf_set_extmark(buffer_id, namespace_id, iline - 1, 0, { - virt_text = padded, - virt_text_pos = "eol", + 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 vtp }) + 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/lua/fidget/poll.lua b/lua/fidget/poll.lua index 07d181f..cd6f720 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,26 +13,23 @@ 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 ----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. +--- +---@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_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 +---@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. @@ -63,63 +47,72 @@ 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() or self.err ~= nil 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) - 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) + if not attack then + attack = 15 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 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.uv.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_t = 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() / 1e9 + -- 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, + end_t, + end_t - self.start_t, + ok and "completion" or "error" + )) + end + if not ok then + self.err = res + if self:raise() then + error(res) + end + logger.error(res) + end + end end end - end)) + end + if notice then + 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, function() vim.schedule(self.callback) end) end --- Call the poll() function once, if the poller isn't already running. @@ -129,14 +122,17 @@ 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(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 @@ -147,14 +143,14 @@ end --- ---@return number function Poller:now() - return self.current_time + return self.current_t 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,13 +160,24 @@ 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 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 {} @@ -186,11 +193,13 @@ 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, + raise = opts.raise or function() return false end, + timer = nil, + start_t = 0, -- log metric + current_t = 0, -- frame time + err = nil, } return setmetatable(poller, Poller) end 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 } diff --git a/tests/notify.lua b/tests/notify.lua new file mode 100644 index 0000000..811c042 --- /dev/null +++ b/tests/notify.lua @@ -0,0 +1,354 @@ +local logger = require("fidget.logger") +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_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!", + 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! + +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 `cøde` here: +```lua +function foo() + print("some tab here") +end +``` +]] +} + +-- a notification window with a message +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) +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) +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" }) +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 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 + 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, 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 + +-- 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, 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" + 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]], 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, nil, { position = "left" }) +end + +-- 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, nil, { position = "left" }) +end + +-- 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 +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 + +-- 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() + -- clean cache etc if needed here +end + +--- +local M = { + --- name test time offset + ---@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_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 }, + { "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 }, + { "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 }, + { "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 }, + { "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_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 }, + }, +} + +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 + -- notif.default_config.ttl = 500 + + M.config = { + 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), + } + 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.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 + logger.debug("run " .. test[1]) + test[2]() + end) + if not ok then + logger.debug("=> " .. err) + end + t:close() + end + )) + end + end +end + +return M