Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge duplicate notifications #278

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lua/notify/config/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions lua/notify/instance.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 10 additions & 3 deletions lua/notify/render/compact.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
3 changes: 3 additions & 0 deletions lua/notify/render/default.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions lua/notify/render/minimal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions lua/notify/render/simple.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions lua/notify/render/wrapped-compact.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions lua/notify/service/notification.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>)
---@field duplicates? integer[] shared list of duplicate notifications by id
local Notification = {}

local level_maps = vim.tbl_extend("keep", {}, vim.log.levels)
Expand Down Expand Up @@ -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)
Expand Down
75 changes: 75 additions & 0 deletions tests/manual/merge_duplicates.lua
Original file line number Diff line number Diff line change
@@ -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)()