diff --git a/lua/notify/config/init.lua b/lua/notify/config/init.lua index 253c9a2..9b020c7 100644 --- a/lua/notify/config/init.lua +++ b/lua/notify/config/init.lua @@ -30,6 +30,7 @@ local default_config = { minimum_width = 50, fps = 30, top_down = true, + merge_duplicates = true, time_formats = { notification_history = "%FT%T", notification = "%T", @@ -58,6 +59,7 @@ local default_config = { ---@field minimum_width integer Minimum width for notification windows ---@field fps integer Frames per second for animation stages, higher value means smoother animations but more CPU usage ---@field top_down boolean whether or not to position the notifications at the top or not +---@field merge_duplicates boolean|integer whether to replace visible notification if new one is the same, can be an integer for min duplicate count local opacity_warned = false @@ -156,6 +158,10 @@ function Config.setup(custom_config) return user_config.top_down end + function config.merge_duplicates() + return user_config.merge_duplicates + end + function config.on_close() return user_config.on_close end diff --git a/lua/notify/instance.lua b/lua/notify/instance.lua index 6830e69..bac54b4 100644 --- a/lua/notify/instance.lua +++ b/lua/notify/instance.lua @@ -6,6 +6,32 @@ local NotificationService = require("notify.service") local NotificationBuf = require("notify.service.buffer") local stage_util = require("notify.stages.util") +local notif_cmp_keys = { + "level", + "message", + "title", + "icon", +} + +---@param n1 notify.Notification +---@param n2 notify.Notification +---@return boolean +local function notifications_equal(n1, n2) + for _, key in ipairs(notif_cmp_keys) do + local v1 = n1[key] + local v2 = n2[key] + -- NOTE: Notification:new adds time string which causes not-equality, so compare only left title (1st element) + if key == "title" then + v1 = v1[1] + v2 = v2[1] + end + if not vim.deep_equal(v1, v2) then + return false + end + end + return true +end + ---@param user_config notify.Config ---@param inherit? boolean Inherit the global configuration, default true ---@param global_config notify.Config @@ -38,8 +64,19 @@ return function(user_config, inherit, global_config) return require("notify.render")[render] end + ---@param notif notify.Notification + ---@return notify.Notification? + local function find_duplicate(notif) + for _, buf in pairs(animator.notif_bufs) do + if notifications_equal(buf._notif, notif) then + return buf._notif + end + end + end + function instance.notify(message, level, opts) opts = opts or {} + if opts.replace then if type(opts.replace) == "table" then opts.replace = opts.replace.id @@ -66,11 +103,27 @@ return function(user_config, inherit, global_config) opts[key] = opts[key] or existing[key] end end + opts.render = get_render(opts.render or instance_config.render()) local id = #notifications + 1 local notification = Notification(id, message, level, opts, instance_config) table.insert(notifications, notification) local level_num = vim.log.levels[notification.level] + + if not opts.replace and instance_config.merge_duplicates() then + local dup = find_duplicate(notification) + if dup then + dup.duplicates = dup.duplicates or { dup.id } + table.insert(dup.duplicates, notification.id) + notification.duplicates = dup.duplicates + + local min_dups = instance_config.merge_duplicates() + if min_dups == true or #notification.duplicates >= min_dups + 1 then + opts.replace = dup.id + end + end + end + if opts.replace then service:replace(opts.replace, notification) elseif not level_num or level_num >= instance_config.level() then diff --git a/lua/notify/render/compact.lua b/lua/notify/render/compact.lua index c6dd025..fea6af6 100644 --- a/lua/notify/render/compact.lua +++ b/lua/notify/render/compact.lua @@ -5,15 +5,22 @@ return function(bufnr, notif, highlights) local icon = notif.icon local title = notif.title[1] + if type(title) == "string" and notif.duplicates then + title = string.format('%s x%d', title, #notif.duplicates) + end + local prefix if type(title) == "string" and #title > 0 then prefix = string.format("%s | %s:", icon, title) else prefix = string.format("%s |", icon) end - notif.message[1] = string.format("%s %s", prefix, notif.message[1]) + local message = { + string.format("%s %s", prefix, notif.message[1]), + unpack(notif.message, 2) + } - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, notif.message) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, message) local icon_length = vim.str_utfindex(icon) local prefix_length = vim.str_utfindex(prefix) @@ -30,7 +37,7 @@ return function(bufnr, notif, highlights) }) vim.api.nvim_buf_set_extmark(bufnr, namespace, 0, prefix_length + 1, { hl_group = highlights.body, - end_line = #notif.message, + end_line = #message, priority = 50, }) end diff --git a/lua/notify/render/default.lua b/lua/notify/render/default.lua index 3213f16..a24fd51 100644 --- a/lua/notify/render/default.lua +++ b/lua/notify/render/default.lua @@ -8,6 +8,9 @@ return function(bufnr, notif, highlights, config) end, notif.message)))) local right_title = notif.title[2] local left_title = notif.title[1] + if notif.duplicates then + left_title = string.format('%s (x%d)', left_title, #notif.duplicates) + end local title_accum = vim.str_utfindex(left_icon) + vim.str_utfindex(right_title) + vim.str_utfindex(left_title) diff --git a/lua/notify/render/minimal.lua b/lua/notify/render/minimal.lua index 9b29eed..08f3525 100644 --- a/lua/notify/render/minimal.lua +++ b/lua/notify/render/minimal.lua @@ -2,13 +2,21 @@ local api = vim.api local base = require("notify.render.base") return function(bufnr, notif, highlights) + local message = notif.message + if notif.duplicates then + message = { + string.format("x%d %s", #notif.duplicates, notif.message[1]), + unpack(notif.message, 2) + } + end + local namespace = base.namespace() - api.nvim_buf_set_lines(bufnr, 0, -1, false, notif.message) + api.nvim_buf_set_lines(bufnr, 0, -1, false, message) api.nvim_buf_set_extmark(bufnr, namespace, 0, 0, { hl_group = highlights.icon, - end_line = #notif.message - 1, - end_col = #notif.message[#notif.message], + end_line = #message - 1, + end_col = #message[#message], priority = 50, }) end diff --git a/lua/notify/render/simple.lua b/lua/notify/render/simple.lua index 5b83130..2bb9b61 100644 --- a/lua/notify/render/simple.lua +++ b/lua/notify/render/simple.lua @@ -6,6 +6,9 @@ return function(bufnr, notif, highlights, config) return vim.fn.strchars(line) end, notif.message)))) local title = notif.title[1] + if notif.duplicates then + title = string.format('%s (x%d)', title, #notif.duplicates) + end local title_accum = vim.str_utfindex(title) local title_buffer = string.rep( diff --git a/lua/notify/render/wrapped-compact.lua b/lua/notify/render/wrapped-compact.lua index 5cf3faf..91e2a24 100644 --- a/lua/notify/render/wrapped-compact.lua +++ b/lua/notify/render/wrapped-compact.lua @@ -59,10 +59,16 @@ return function(bufnr, notif, highlights, config) if has_valid_manual_title then -- has title = icon + title as header row prefix = string.format(" %s %s", icon, title) + if notif.duplicates then + prefix = string.format('%s x%d', prefix, #notif.duplicates) + end table.insert(message, 1, prefix) else -- no title = prefix the icon prefix = string.format(" %s", icon) + if notif.duplicates then + prefix = string.format('%s x%d', prefix, #notif.duplicates) + end message[1] = string.format("%s %s", prefix, message[1]) end diff --git a/lua/notify/service/notification.lua b/lua/notify/service/notification.lua index b464f7b..537b0bf 100644 --- a/lua/notify/service/notification.lua +++ b/lua/notify/service/notification.lua @@ -13,6 +13,7 @@ ---@field on_open fun(win: number, record: notify.Record) | nil ---@field on_close fun(win: number, record: notify.Record) | nil ---@field render fun(buf: integer, notification: notify.Notification, highlights: table) +---@field duplicates? integer[] shared list of duplicate notifications by id local Notification = {} local level_maps = vim.tbl_extend("keep", {}, vim.log.levels) @@ -52,6 +53,7 @@ function Notification:new(id, message, level, opts, config) animate = opts.animate ~= false, render = opts.render, hide_from_history = opts.hide_from_history, + duplicates = opts.duplicates, } self.__index = self setmetatable(notif, self) diff --git a/tests/manual/merge_duplicates.lua b/tests/manual/merge_duplicates.lua new file mode 100644 index 0000000..a15ad5e --- /dev/null +++ b/tests/manual/merge_duplicates.lua @@ -0,0 +1,75 @@ +local function coroutine_resume() + local co = assert(coroutine.running()) + return function(...) + local ret = { coroutine.resume(co, ...) } + -- Re-raise errors with correct traceback + local ok, err = unpack(ret) + if not ok then + error(debug.traceback(co, err)) + end + return unpack(ret, 2) + end +end + +local function coroutine_sleep() + local resume = coroutine_resume() + return function(ms) + vim.defer_fn(resume, ms) + coroutine.yield() + end +end + +local function run() + local sleep = coroutine_sleep() + + local countdown = function(seconds) + local id + for i = 1, seconds do + -- local msg = string.format('Will show once again in %d seconds', seconds - i + 1) + local msg = string.format('Will show once again in %d seconds', seconds - i + 1) + id = vim.notify(msg, vim.log.levels.WARN, { replace = id }).id + sleep(1000) + end + end + + local texts = { + 'AAA This is first text', + 'BBBBBB Second text of duplicate notifications', + -- 'CCCCCCCCC Third text', + } + + local show_all = function(level) + for _, text in ipairs(texts) do + vim.notify(text, level) + sleep(50) + end + end + + show_all() + + sleep(1000) + show_all() + + sleep(1000) + show_all() + + sleep(1000) + show_all(vim.log.levels.WARN) + sleep(1000) + show_all(vim.log.levels.WARN) + + sleep(1000) + show_all() + + -- wait until the previous notifications disappear + countdown(10) + show_all() + + sleep(2000) + for _ = 1, 41 do + sleep(50) + show_all() + end +end + +coroutine.wrap(run)()