diff --git a/README.md b/README.md index 5345b1a..ae00ad5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A fancy, configurable, notification manager for NeoVim -![notify](https://user-images.githubusercontent.com/24252670/128085627-d1c0d929-5a98-4743-9cf0-5c1bc3367f0a.gif) +![notify](https://user-images.githubusercontent.com/24252670/130856848-e8289850-028f-4f49-82f1-5ea1b8912f5e.gif) Credit to [sunjon](https://github.com/sunjon) for [the design](https://neovim.discourse.group/t/wip-animated-notifications-plugin/448) that inspired the appearance of this plugin. @@ -23,6 +23,7 @@ vim.notify = require("notify") ``` You can supply a level to change the border highlighting + ```lua vim.notify("This is an error message", "error") ``` @@ -34,8 +35,10 @@ There are a number of custom options that can be supplied in a table as the thir - `on_close`: A function to call with the window ID as an argument after closing - `title`: Title string for the header - `icon`: Icon to use for the header +- `keep`: Function that returns whether or not to keep the window open instead of using a timeout Sample code for the GIF above: + ```lua local plugin = "My Awesome Plugin" @@ -62,4 +65,147 @@ vim.notify("This is an error message.\nSomething went wrong!", "error", { ## Configuration -TODO! +### Setup + +You can optionally call the `setup` function to provide configuration options + +Default Config: + +```lua +require("notify").setup({ + -- Animation style (see below for details) + stages = "fade_in_slide_out", + + -- Default timeout for notifications + timeout = 5000, + + -- For stages that change opacity this is treated as the highlight behind the window + background_colour = "Normal", + + -- Icons for the different levels + icons = { + ERROR = "", + WARN = "", + INFO = "", + DEBUG = "", + TRACE = "✎", + }, +}) +``` + +### Highlights + +You can define custom highlights by supplying highlight groups for each of the levels. +The naming scheme follows a simple structure: `Notify
` + +Here are the defaults: + +```vim +highlight NotifyERRORBorder guifg=#8A1F1F +highlight NotifyWARNBorder guifg=#79491D +highlight NotifyINFOBorder guifg=#4F6752 +highlight NotifyDEBUGBorder guifg=#8B8B8B +highlight NotifyTRACEBorder guifg=#4F3552 +highlight NotifyERRORIcon guifg=#F70067 +highlight NotifyWARNIcon guifg=#F79000 +highlight NotifyINFOIcon guifg=#A9FF68 +highlight NotifyDEBUGIcon guifg=#8B8B8B +highlight NotifyTRACEIcon guifg=#D484FF +highlight NotifyERRORTitle guifg=#F70067 +highlight NotifyWARNTitle guifg=#F79000 +highlight NotifyINFOTitle guifg=#A9FF68 +highlight NotifyDEBUGTitle guifg=#8B8B8B +highlight NotifyTRACETitle guifg=#D484FF +highlight link NotifyERRORBody Normal +highlight link NotifyWARNBody Normal +highlight link NotifyINFOBody Normal +highlight link NotifyDEBUGBody Normal +highlight link NotifyTRACEBody Normal +``` + +### Animation Style + +The animation is designed to work in stages. The first stage is the opening of +the window, and all subsequent stages can changes the position or opacity of +the window. You can use one of the built-in styles or provide your own in the setup. + +1. "fade_in_slide_out" + +![fade_slide](https://user-images.githubusercontent.com/24252670/130924913-f3a61f2c-2330-4426-a787-3cd7494fccc0.gif) + +2. "fade" + +![fade](https://user-images.githubusercontent.com/24252670/130924911-a89bef9b-e815-4aa5-a255-84bc23dd8c8e.gif) + +3. "slide" + +![slide](https://user-images.githubusercontent.com/24252670/130924905-656cabfc-9eb7-4e22-b6da-8a2a1f508fa5.gif) + +4. "static" + +![static](https://user-images.githubusercontent.com/24252670/130924902-8c77b5a1-6d13-48f4-98a9-866e58cb76e4.gif) + +Custom styles can be provided by setting the config `stages` value to a list of +functions. + +If you create a custom style, feel free to open a PR to submit it as a built-in style! + +**NB.** This is a prototype API that is open to change. I am looking for +feedback on both issues or extra data that could be useful in creating +animation styles. + +Check the [built-in styles](./lua/notify/stages/) to see examples + +#### Opening the window + +The first function in the list should return a table to be provided to +`nvim_open_win`, optionally including an extra `opacity` key which can be +between 0-100. + +The function is given a state table that contains the following keys: + +- `message: table` State of the message to be shown + - `width` Width of the message buffer + - `height` Height of the message buffer +- `open_windows: integer[]` List of all window IDs currently showing messages + +If a notification can't be shown at the moment the function should return `nil`. + +#### Changing the window + +All following functions should return the goal values for the window to reach from it's current point. +They will receive the same state object as the initial function and a second argument of the window ID. + +The following fields can be returned in a table: +- `col` +- `row` +- `height` +- `width` +- `opacity` + +These can be provided as either numbers or as a table. If they are +provided as numbers then they will change instantly the value given. + +If they are provided as a table, they will be treated as a value to animate towards. +This uses a dampened spring algorithm to provide a natural feel to the movement. + +The table must contain the goal value as the 1st index (e.g. `{10}`) + +All other values are provided with keys: + +- `damping: number` How motion decays over time. Values less than 1 mean the spring can overshoot. + - Bounds: >= 0 + - Default: 1 +- `frequency: number` How fast the spring oscillates + - Bounds: >= 0 + - Default: 1 +- `complete: fun(value: number): bool` Function to determine if value has reached its goal. If not + provided it will complete when the value rounded to 2 decimal places is equal + to the goal. + +Once the last function has reached its goals, the window is removed. + +One of the stages should also return the key `time` set to true. This is +treated as the stage which the notification is on a timer. The goals of this +stage are not used to check if it is complete. The next stage will start +once the notification reaches its timeout. diff --git a/lua/notify/animate/spring.lua b/lua/notify/animate/spring.lua index 9bbfdac..8affca5 100644 --- a/lua/notify/animate/spring.lua +++ b/lua/notify/animate/spring.lua @@ -7,8 +7,8 @@ local cos = math.cos local sqrt = math.sqrt ---@class SpringState ----@field position number ----@field goal number +---@field position number | string +---@field goal number | string ---@field velocity number | nil ---@field damping number ---@field frequency number @@ -16,9 +16,10 @@ local sqrt = math.sqrt ---@param dt number @Step in time ---@param state SpringState ---@return SpringState -return function(dt, state) - local damping = state.damping - local angular_freq = state.frequency * 2 * pi +return function(dt, state, settings) + local damping = settings.damping + local angular_freq = settings.frequency * 2 * pi + local cur_os = state.position local cur_vel = state.velocity or 0 local goal = state.goal diff --git a/lua/notify/config/highlights.lua b/lua/notify/config/highlights.lua index 6a0c072..76ae73a 100644 --- a/lua/notify/config/highlights.lua +++ b/lua/notify/config/highlights.lua @@ -1,27 +1,37 @@ local M = {} function M.setup() - vim.cmd[[ - hi default NotifyERROR guifg=#8A1F1F - hi default NotifyWARN guifg=#79491D - hi default NotifyINFO guifg=#4F6752 - hi default NotifyDEBUG guifg=#8B8B8B - hi default NotifyTRACE guifg=#4F3552 - hi default NotifyERRORTitle guifg=#F70067 - hi default NotifyWARNTitle guifg=#F79000 - hi default NotifyINFOTitle guifg=#A9FF68 - hi default NotifyDEBUGTitle guifg=#8B8B8B - hi default NotifyTRACETitle guifg=#D484FF - ]] + vim.cmd([[ + hi default NotifyERRORBorder guifg=#8A1F1F + hi default NotifyWARNBorder guifg=#79491D + hi default NotifyINFOBorder guifg=#4F6752 + hi default NotifyDEBUGBorder guifg=#8B8B8B + hi default NotifyTRACEBorder guifg=#4F3552 + hi default NotifyERRORIcon guifg=#F70067 + hi default NotifyWARNIcon guifg=#F79000 + hi default NotifyINFOIcon guifg=#A9FF68 + hi default NotifyDEBUGIcon guifg=#8B8B8B + hi default NotifyTRACEIcon guifg=#D484FF + hi default NotifyERRORTitle guifg=#F70067 + hi default NotifyWARNTitle guifg=#F79000 + hi default NotifyINFOTitle guifg=#A9FF68 + hi default NotifyDEBUGTitle guifg=#8B8B8B + hi default NotifyTRACETitle guifg=#D484FF + hi default link NotifyERRORBody Normal + hi default link NotifyWARNBody Normal + hi default link NotifyINFOBody Normal + hi default link NotifyDEBUGBody Normal + hi default link NotifyTRACEBody Normal + ]]) end M.setup() -vim.cmd[[ - augroup nvim_notify +vim.cmd([[ + augroup NvimNotifyRefreshHighlights autocmd! autocmd ColorScheme * lua require('notify.config.highlights').setup() augroup END -]] +]]) return M diff --git a/lua/notify/config/init.lua b/lua/notify/config/init.lua index 723ad6f..849440c 100644 --- a/lua/notify/config/init.lua +++ b/lua/notify/config/init.lua @@ -2,7 +2,17 @@ local M = {} require("notify.config.highlights") +local BUILTIN_STAGES = { + FADE = "fade", + SLIDE = "slide", + FADE_IN_SLIDE_OUT = "fade_in_slide_out", + STATIC = "static", +} + local default_config = { + timeout = 5000, + stages = BUILTIN_STAGES.FADE_IN_SLIDE_OUT, + background_colour = "Normal", icons = { ERROR = "", WARN = "", @@ -14,14 +24,57 @@ local default_config = { local user_config = default_config +local function validate_highlight(colour_or_group, needs_opacity) + if colour_or_group:sub(1, 1) == "#" then + return colour_or_group + end + local group_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(colour_or_group)), "bg") + if group_bg == "" then + if needs_opacity then + vim.notify( + "Highlight group '" + .. colour_or_group + .. "' has no background highlight.\n\n" + .. "Please provide an RGB hex value or highlight group with a background value for 'background_colour' option\n\n" + .. "Defaulting to #000000", + "warn", + { title = "nvim-notify" } + ) + end + return "#000000" + end + return group_bg +end + function M.setup(config) local filled = vim.tbl_deep_extend("keep", config or {}, default_config) user_config = filled - require("dapui.config.highlights").setup() + local stages = M.stages() + + local needs_opacity = vim.tbl_contains( + { BUILTIN_STAGES.FADE_IN_SLIDE_OUT, BUILTIN_STAGES.FADE }, + stages + ) + + user_config.background_colour = validate_highlight(user_config.background_colour, needs_opacity) +end + +---@param colour_or_group string + +function M.background_colour() + return user_config.background_colour end function M.icons() return user_config.icons end +function M.stages() + return user_config.stages +end + +function M.default_timeout() + return user_config.timeout +end + return M diff --git a/lua/notify/init.lua b/lua/notify/init.lua index 6ad4a9e..ec6a962 100644 --- a/lua/notify/init.lua +++ b/lua/notify/init.lua @@ -1,82 +1,37 @@ -local NotificationRenderer = require("notify.render") -local config = require("notify.config") - -local renderer = NotificationRenderer() - -local running = false - -local function run() - running = true - local succees, ran = pcall(renderer.step, renderer, 30 / 1000) - if not succees then - print("Error running notification service: " .. ran) - running = false - return - end - if not ran then - running = false - return - end - vim.defer_fn(run, 30) -end - -local notifications = {} - ----@class Notification ----@field level string ----@field message string ----@field timeout number ----@field title string ----@field icon string ----@field time number ----@field width number ----@field on_open fun(win: number) | nil ----@field on_close fun(win: number) | nil -local Notification = {} - -function Notification:new(message, level, opts) - if type(level) == "number" then - level = vim.lsp.log_levels[level] - end - if type(message) == "string" then - message = vim.split(message, "\n") - end - level = vim.fn.toupper(level or "info") - local notif = { - message = message, - title = opts.title or "", - icon = opts.icon or config.icons()[level] or config.icons().INFO, - time = vim.fn.localtime(), - timeout = opts.timeout or 5000, - level = level, - on_open = opts.on_open, - on_close = opts.on_close, - } - self.__index = self - setmetatable(notif, self) - return notif +local util = require("notify.util") + +local config = util.lazy_require("notify.config") +local stages = util.lazy_require("notify.stages") +---@type fun(stages: function[]): WindowAnimator +local WindowAnimator = util.lazy_require("notify.windows") +---@type fun(receiver: fun(pending: FIFOQueue, time: number): table | nil): NotificationService +local NotificationService = util.lazy_require("notify.service") + +local service + +local function setup(user_config) + config.setup(user_config) + local animator_stages = config.stages() + animator_stages = type(animator_stages) == "string" and stages[animator_stages] or animator_stages + local animator = WindowAnimator(animator_stages) + service = NotificationService(function(...) + return animator:render(...) + end) end ----@class NotifyOptions ----@field title string | nil ----@field icon string | nil ----@field timeout number | nil ----@field on_open fun(win: number) | nil ----@field on_close fun(win: number) | nil - +---@param message string | string[] +---@param level string | number ---@param opts NotifyOptions local function notify(_, message, level, opts) vim.schedule(function() - local notif = Notification:new(message, level, opts or {}) - notifications[#notifications + 1] = notif - renderer:queue(notif) - if not running then - run() + if not service then + setup() end + service:push(message, level, opts) end) end -local M = {} +local M = { setup = setup } setmetatable(M, { __call = notify }) diff --git a/lua/notify/render.lua b/lua/notify/render.lua deleted file mode 100644 index 5f600d4..0000000 --- a/lua/notify/render.lua +++ /dev/null @@ -1,280 +0,0 @@ -local api = vim.api -local namespace = api.nvim_create_namespace("nvim-notify") -local animate = require("notify.animate") -local util = require("notify.util") - -local WinStage = { - OPENING = "opening", - OPEN = "open", - CLOSING = "closing", -} - ----@class NotificationRenderer ----@field pending FIFOQueue ----@field win_states table> ----@field win_stages table ----@field win_width table ----@field notifications table -local NotificationRenderer = {} - -function NotificationRenderer:new() - local sender = { - win_stages = {}, - win_states = {}, - pending = util.FIFOQueue(), - win_width = {}, - notifications = {}, - } - self.__index = self - setmetatable(sender, self) - return sender -end - -function NotificationRenderer:step(time) - self:push_pending() - if vim.tbl_isempty(self.win_stages) then - return false - end - self:update_states(time) - self:render_windows() - self:advance_stages() - return true -end - -function NotificationRenderer:window_intervals() - local win_intervals = {} - for w, _ in pairs(self.win_stages) do - local exists, existing_conf = util.get_win_config(w) - if exists then - win_intervals[#win_intervals + 1] = { - existing_conf.row, - existing_conf.row + existing_conf.height + 2, - } - end - end - table.sort(win_intervals, function(a, b) - return a[1] < b[1] - end) - return win_intervals -end - -function NotificationRenderer:push_pending() - if self.pending:is_empty() then - return - end - while not self.pending:is_empty() do - local next_notif = self.pending:peek() - local next_height = #next_notif.message + 3 -- Title and borders - - local next_row = vim.opt.tabline:get() == "" and 0 or 1 - for _, interval in pairs(self:window_intervals()) do - local next_bottom = next_row + next_height - if interval[1] <= next_bottom then - next_row = interval[2] - else - break - end - end - - if next_row + next_height >= vim.opt.lines:get() then - return - end - - self:add_window(next_notif, next_row) - self.pending:pop() - end -end - -function NotificationRenderer:advance_stages() - for win, _ in pairs(self.win_stages) do - local complete = self:is_stage_complete(win) - if complete then - self:advance_stage(win) - end - end -end - -function NotificationRenderer:is_stage_complete(win) - local stage = self.win_stages[win] - if stage == WinStage.OPENING then - for _, state in pairs(self.win_states[win] or {}) do - if state.goal ~= util.round(state.position, 2) then - return false - end - end - end - if stage == WinStage.OPEN then - return false -- Updated by timer - end - if stage == WinStage.CLOSING then - if self.win_states[win].width.position >= 2 then - return false - end - end - return true -end - -function NotificationRenderer:advance_stage(win) - local cur_stage = self.win_stages[win] - if cur_stage == WinStage.OPENING then - self.win_stages[win] = WinStage.OPEN - local function close() - if api.nvim_get_current_win() ~= win then - return self:advance_stage(win) - end - vim.defer_fn(close, 1000) - end - vim.defer_fn(close, self.notifications[win].timeout) - elseif cur_stage == WinStage.OPEN then - self.win_stages[win] = WinStage.CLOSING - else - local success = pcall(api.nvim_win_close, win, true) - if not success then - self:remove_win_state(win) - return - end - local notif = self.notifications[win] - self:remove_win_state(win) - if notif.on_close then - notif.on_close(win) - end - end -end - -function NotificationRenderer:remove_win_state(win) - self.win_stages[win] = nil - self.win_states[win] = nil - self.notifications[win] = nil -end - -function NotificationRenderer:update_states(time) - local updated_states = {} - for win, _ in pairs(self.win_stages) do - local states = self:stage_state(win) - if states then - updated_states[win] = vim.tbl_map(function(state) - return animate.spring(time, state) - end, states) - end - end - self.win_states = updated_states -end - -function NotificationRenderer:stage_state(win) - local cur_state = self.win_states[win] or {} - local exists, win_conf = util.get_win_config(win) - if not exists then - self:remove_win_state(win) - return - end - local new_state = {} - local goals = self:stage_goals(win) - for field, goal in pairs(goals) do - local cur_field_state = cur_state[field] or {} - local cur_stage = self.win_stages[win] - new_state[field] = { - position = cur_field_state.position or win_conf[field], - velocity = cur_field_state.velocity, - goal = goal, - frequency = 2, - damping = cur_stage == WinStage.CLOSING and 0.6 or 1, - } - end - return new_state -end - -function NotificationRenderer:stage_goals(win) - local create_goals = ({ - [WinStage.OPENING] = function() - return { - width = self.win_width[win], - col = vim.opt.columns:get(), - } - end, - [WinStage.OPEN] = function() - return { - col = vim.opt.columns:get(), - } - end, - [WinStage.CLOSING] = function() - return { - width = 1, - col = vim.opt.columns:get(), - } - end, - })[self.win_stages[win]] - - return create_goals() -end - -function NotificationRenderer:render_windows() - for win, states in pairs(self.win_states) do - local exists, conf = util.get_win_config(win) - if exists then - for field, state in pairs(states) do - conf[field] = state.position - end - util.set_win_config(win, conf) - else - self:remove_win_state(win) - end - end -end - ----@param notif Notification -function NotificationRenderer:add_window(notif, row) - local buf = vim.api.nvim_create_buf(false, true) - local message_line = 0 - local right_title = vim.fn.strftime("%H:%M", notif.time) - local left_title = " " .. notif.icon .. " " .. (notif.title or "") - local win_width = math.max( - math.max(unpack(vim.tbl_map(function(line) - return vim.fn.strchars(line) - end, notif.message))), - vim.fn.strchars(left_title .. right_title), - 50 - ) - if notif.title then - message_line = 2 - local title_line = left_title - .. string.rep(" ", win_width - vim.fn.strchars(left_title .. right_title)) - .. right_title - vim.api.nvim_buf_set_lines(buf, 0, 1, false, { title_line, string.rep("━", win_width) }) - vim.api.nvim_buf_add_highlight(buf, namespace, "Notify" .. notif.level .. "Title", 0, 0, -1) - vim.api.nvim_buf_add_highlight(buf, namespace, "Notify" .. notif.level, 1, 0, -1) - end - vim.api.nvim_buf_set_lines(buf, message_line, message_line + #notif.message, false, notif.message) - vim.api.nvim_buf_set_option(buf, "modifiable", false) - - local win_opts = { - relative = "editor", - anchor = "NE", - width = 1, - height = message_line + #notif.message, - col = vim.opt.columns:get(), - row = row, - border = "rounded", - style = "minimal", - } - - local win = vim.api.nvim_open_win(buf, false, win_opts) - vim.wo[win].winhl = "Normal:Normal,FloatBorder:Notify" .. notif.level - vim.wo[win].wrap = false - - self.win_stages[win] = WinStage.OPENING - self.win_width[win] = win_width - self.notifications[win] = notif - if notif.on_open then - notif.on_open(win) - end -end - ----@param notif Notification -function NotificationRenderer:queue(notif) - self.pending:push(notif) -end - ----@return NotificationRenderer -return function() - return NotificationRenderer:new() -end diff --git a/lua/notify/service/buffer/highlights.lua b/lua/notify/service/buffer/highlights.lua new file mode 100644 index 0000000..cf71552 --- /dev/null +++ b/lua/notify/service/buffer/highlights.lua @@ -0,0 +1,70 @@ +local config = require("notify.config") +local util = require("notify.util") + +---@class NotifyBufHighlights +---@field groups table +---@field opacity number +---@field title string +---@field border string +---@field icon string +---@field body string +local NotifyBufHighlights = {} + +local function group_fields(group) + return { + guifg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(group)), "fg"), + guibg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(group)), "bg"), + } +end + +function NotifyBufHighlights:new(level, buffer) + local function linked_group(section) + local orig = "Notify" .. level .. section + local new = orig .. buffer + vim.cmd("hi link " .. new .. " " .. orig) + return new + end + local title = linked_group("Title") + local border = linked_group("Border") + local body = linked_group("Body") + local icon = linked_group("Icon") + + local groups = {} + for _, group in pairs({ title, border, body, icon }) do + groups[group] = group_fields(group) + end + local buf_highlights = { + groups = groups, + opacity = 100, + border = border, + body = body, + title = title, + icon = icon, + } + self.__index = self + setmetatable(buf_highlights, self) + return buf_highlights +end + +function NotifyBufHighlights:set_opacity(alpha) + self.opacity = alpha + local background = config.background_colour() + for group, fields in pairs(self.groups) do + local updated_fields = {} + for name, value in pairs(fields) do + if value ~= "" then + updated_fields[name] = util.blend(value, background, alpha / 100) + end + end + util.highlight(group, updated_fields) + end +end + +function NotifyBufHighlights:get_opacity() + return self.opacity +end + +---@return NotifyBufHighlights +return function(level, buffer) + return NotifyBufHighlights:new(level, buffer) +end diff --git a/lua/notify/service/buffer/init.lua b/lua/notify/service/buffer/init.lua new file mode 100644 index 0000000..c962f6d --- /dev/null +++ b/lua/notify/service/buffer/init.lua @@ -0,0 +1,135 @@ +local api = vim.api +local namespace = api.nvim_create_namespace("nvim-notify") + +local NotifyBufHighlights = require("notify.service.buffer.highlights") + +---@class NotificationBuf +---@field highlights NotifyBufHighlights +---@field _notif Notification +---@field _state "open" | "closed" +---@field _buffer number +---@field _height number +---@field _width number +local NotificationBuf = {} + +local BufState = { + OPEN = "open", + CLOSED = "close", +} + +function NotificationBuf:new(kwargs) + local notif_buf = { + _notif = kwargs.notif, + _buffer = kwargs.buffer, + _state = BufState.CLOSED, + _width = 0, + _height = 0, + highlights = NotifyBufHighlights(kwargs.notif.level, kwargs.buffer), + } + setmetatable(notif_buf, self) + self.__index = self + return notif_buf +end + +function NotificationBuf:open(win) + if self._state ~= BufState.CLOSED then + return + end + self._state = BufState.OPEN + if self._notif.on_open then + self._notif.on_open(win) + end +end + +function NotificationBuf:close(win) + if self._state ~= BufState.OPEN then + return + end + self._state = BufState.CLOSED + if self._notif.on_close then + self._notif.on_close(win) + end +end + +function NotificationBuf:height() + return self._height +end + +function NotificationBuf:width() + return self._width +end + +function NotificationBuf:should_stay() + if self._notif.keep then + return self._notif.keep() + end + return false +end + +function NotificationBuf:render() + local notif = self._notif + local buf = self._buffer + + api.nvim_buf_set_option(buf, "modifiable", true) + + local right_title = vim.fn.strftime("%H:%M", notif.time) + local left_icon = notif.icon .. " " + local max_width = math.max( + math.max(unpack(vim.tbl_map(function(line) + return vim.fn.strchars(line) + end, notif.message))), + 50 + ) + local left_title = (notif.title or "") .. string.rep(" ", max_width) + api.nvim_buf_set_lines(buf, 0, 1, false, { "", "" }) + api.nvim_buf_set_extmark(buf, namespace, 0, 0, { + virt_text = { + { " " }, + { left_icon, self.highlights.icon }, + { left_title, self.highlights.title }, + }, + virt_text_win_col = 0, + priority = max_width, + }) + api.nvim_buf_set_extmark(buf, namespace, 0, 0, { + virt_text = { { right_title, self.highlights.title }, { " " } }, + virt_text_pos = "right_align", + priority = max_width, + }) + api.nvim_buf_set_extmark(buf, namespace, 1, 0, { + virt_text = { { string.rep("━", max_width), self.highlights.border } }, + virt_text_win_col = 0, + priority = max_width, + }) + api.nvim_buf_set_lines(buf, 2, 2 + #notif.message, false, notif.message) + + api.nvim_buf_set_extmark(buf, namespace, 2, 0, { + hl_group = self.highlights.body, + end_line = 1 + #notif.message, + end_col = #notif.message[#notif.message], + }) + + api.nvim_buf_set_option(buf, "modifiable", false) + + self._width = max_width + self._height = 2 + #notif.message +end + +function NotificationBuf:timeout() + return self._notif.timeout +end + +function NotificationBuf:buffer() + return self._buffer +end + +function NotificationBuf:level() + return self._notif.level +end + +---@param buf number +---@param notification Notification +---@return NotificationBuf +return function(buf, notification) + return NotificationBuf:new({ buffer = buf, notif = notification }) +end diff --git a/lua/notify/service/init.lua b/lua/notify/service/init.lua new file mode 100644 index 0000000..b7d51fb --- /dev/null +++ b/lua/notify/service/init.lua @@ -0,0 +1,59 @@ +local util = require("notify.util") +local NotificationBuf = require("notify.service.buffer") +local Notification = require("notify.service.notification") + +---@class NotificationService +---@field private _running boolean +---@field private _pending FIFOQueue +---@field private _receiver fun(pending: FIFOQueue, time: number): boolean +---@field private _notifications Notification[] +local NotificationService = {} + +function NotificationService:new(receiver) + local service = { + _receiver = receiver, + _pending = util.FIFOQueue(), + _running = false, + _notifications = {}, + } + self.__index = self + setmetatable(service, self) + return service +end + +function NotificationService:_run() + self._running = true + local succees, updated = pcall(self._receiver, self._pending, 30 / 1000) + if not succees then + print("Error running notification service: " .. updated) + self._running = false + return + end + if not updated then + self._running = false + return + end + vim.defer_fn(function() + self:_run() + end, 30) +end + +---@param message string | string[] +---@param level string | number +---@param opts NotifyOptions +function NotificationService:push(message, level, opts) + local notif = Notification(message, level, opts or {}) + local buf = vim.api.nvim_create_buf(false, true) + local notif_buf = NotificationBuf(buf, notif) + notif_buf:render() + self._pending:push(notif_buf) + if not self._running then + self:_run() + end +end + +---@param receiver fun(pending: FIFOQueue, time: number): boolean +---@return NotificationService +return function(receiver) + return NotificationService:new(receiver) +end diff --git a/lua/notify/service/notification.lua b/lua/notify/service/notification.lua new file mode 100644 index 0000000..a5b27fa --- /dev/null +++ b/lua/notify/service/notification.lua @@ -0,0 +1,53 @@ +local config = require("notify.config") + +---@class Notification +---@field level string +---@field message string +---@field timeout number | nil +---@field title string +---@field icon string +---@field time number +---@field width number +---@field keep fun(): boolean +---@field on_open fun(win: number) | nil +---@field on_close fun(win: number) | nil +local Notification = {} + +function Notification:new(message, level, opts) + if type(level) == "number" then + level = vim.lsp.log_levels[level] + end + if type(message) == "string" then + message = vim.split(message, "\n") + end + level = vim.fn.toupper(level or "info") + local notif = { + message = message, + title = opts.title or "", + icon = opts.icon or config.icons()[level] or config.icons().INFO, + time = vim.fn.localtime(), + timeout = opts.timeout, + level = level, + keep = opts.keep, + on_open = opts.on_open, + on_close = opts.on_close, + } + self.__index = self + setmetatable(notif, self) + return notif +end + +---@class NotifyOptions +---@field title string | nil +---@field icon string | nil +---@field timeout number | nil +---@field on_open fun(win: number) | nil +---@field on_close fun(win: number) | nil +---@field keep fun(win: number): boolean | nil + +---@param message string | string[] +---@param level string | number +---@param opts NotifyOptions +return function(message, level, opts) + return Notification:new(message, level, opts) +end diff --git a/lua/notify/stages/fade.lua b/lua/notify/stages/fade.lua new file mode 100644 index 0000000..4642c15 --- /dev/null +++ b/lua/notify/stages/fade.lua @@ -0,0 +1,46 @@ +local stages_util = require("notify.stages.util") + +return { + function(state) + local next_height = state.message.height + 2 + local next_row = stages_util.available_row(state.open_windows, next_height) + if not next_row then + return nil + end + return { + relative = "editor", + anchor = "NE", + width = state.message.width, + height = state.message.height, + col = vim.opt.columns:get(), + row = next_row, + border = "rounded", + style = "minimal", + opacity = 0, + } + end, + function() + return { + opacity = { 100 }, + col = { vim.opt.columns:get() }, + } + end, + function() + return { + col = { vim.opt.columns:get() }, + time = true, + } + end, + function() + return { + opacity = { + 0, + frequency = 2, + complete = function(cur_opacity) + return cur_opacity <= 4 + end, + }, + col = { vim.opt.columns:get() }, + } + end, +} diff --git a/lua/notify/stages/fade_in_slide_out.lua b/lua/notify/stages/fade_in_slide_out.lua new file mode 100644 index 0000000..3fc17ad --- /dev/null +++ b/lua/notify/stages/fade_in_slide_out.lua @@ -0,0 +1,54 @@ +local stages_util = require("notify.stages.util") + +return { + function(state) + local next_height = state.message.height + 2 + local next_row = stages_util.available_row(state.open_windows, next_height) + if not next_row then + return nil + end + return { + relative = "editor", + anchor = "NE", + width = state.message.width, + height = state.message.height, + col = vim.opt.columns:get(), + row = next_row, + border = "rounded", + style = "minimal", + opacity = 0, + } + end, + function() + return { + opacity = { 100 }, + col = { vim.opt.columns:get() }, + } + end, + function() + return { + col = { vim.opt.columns:get() }, + time = true, + } + end, + function() + return { + width = { + 1, + frequency = 2.5, + damping = 0.9, + complete = function(cur_width) + return cur_width < 3 + end, + }, + opacity = { + 0, + frequency = 2, + complete = function(cur_opacity) + return cur_opacity <= 4 + end, + }, + col = { vim.opt.columns:get() }, + } + end, +} diff --git a/lua/notify/stages/init.lua b/lua/notify/stages/init.lua new file mode 100644 index 0000000..983e488 --- /dev/null +++ b/lua/notify/stages/init.lua @@ -0,0 +1,20 @@ +local M = {} + +---@class MessageState +---@field width number +---@field height number + +---@alias InitStage fun(open_windows: number[], message_state: MessageState): table | nil +---@alias AnimationStage fun(win: number, message_state: MessageState): table + +---@alias Stage InitStage | AnimationStage +---@alias Stages Stage[] + +setmetatable(M, { + ---@return Stages + __index = function(_, key) + return require("notify.stages." .. key) + end, +}) + +return M diff --git a/lua/notify/stages/slide.lua b/lua/notify/stages/slide.lua new file mode 100644 index 0000000..d48c7e2 --- /dev/null +++ b/lua/notify/stages/slide.lua @@ -0,0 +1,46 @@ +local stages_util = require("notify.stages.util") + +return { + function(state) + local next_height = state.message.height + 2 + local next_row = stages_util.available_row(state.open_windows, next_height) + if not next_row then + return nil + end + return { + relative = "editor", + anchor = "NE", + width = 1, + height = state.message.height, + col = vim.opt.columns:get(), + row = next_row, + border = "rounded", + style = "minimal", + } + end, + function(state) + return { + width = { state.message.width, frequency = 2 }, + col = { vim.opt.columns:get() }, + } + end, + function() + return { + col = { vim.opt.columns:get() }, + time = true, + } + end, + function() + return { + width = { + 1, + frequency = 2.5, + damping = 0.9, + complete = function(cur_width) + return cur_width < 2 + end, + }, + col = { vim.opt.columns:get() }, + } + end, +} diff --git a/lua/notify/stages/static.lua b/lua/notify/stages/static.lua new file mode 100644 index 0000000..7d52040 --- /dev/null +++ b/lua/notify/stages/static.lua @@ -0,0 +1,27 @@ +local stage_util = require("notify.stages.util") + +return { + function(state) + local next_height = state.message.height + 2 + local next_row = stage_util.available_row(state.open_windows, next_height) + if not next_row then + return nil + end + return { + relative = "editor", + anchor = "NE", + width = state.message.width, + height = state.message.height, + col = vim.opt.columns:get(), + row = next_row, + border = "rounded", + style = "minimal", + } + end, + function() + return { + col = { vim.opt.columns:get() }, + time = true, + } + end, +} diff --git a/lua/notify/stages/util.lua b/lua/notify/stages/util.lua new file mode 100644 index 0000000..4424369 --- /dev/null +++ b/lua/notify/stages/util.lua @@ -0,0 +1,55 @@ +local util = require("notify.util") + +local M = {} + +---@param windows number[] +---@param direction "vertical" | "horizontal" +local function window_intervals(windows, direction) + local win_intervals = {} + for _, w in pairs(windows) do + local exists, existing_conf = util.get_win_config(w) + if exists then + local slot_key = direction == "horizontal" and "col" or "row" + local space_key = direction == "horizontal" and "width" or "height" + local border_space = existing_conf.border and 2 or 0 + win_intervals[#win_intervals + 1] = { + existing_conf[slot_key], + existing_conf[slot_key] + existing_conf[space_key] + border_space, + } + end + end + table.sort(win_intervals, function(a, b) + return a[1] < b[1] + end) + return win_intervals +end + +---@param existing_wins number[] +---@param required_height number Window height including borders +function M.available_row(existing_wins, required_height) + local next_row = vim.opt.tabline:get() == "" and 0 or 1 + for _, interval in pairs(window_intervals(existing_wins, "vertical")) do + local next_bottom = next_row + required_height + if interval[1] <= next_bottom then + next_row = interval[2] + else + break + end + end + + if next_row + required_height >= vim.opt.lines:get() then + return nil + end + + return next_row +end + +function M.open_win(notif_buf, opts) + local win = vim.api.nvim_open_win(notif_buf:buffer(), false, opts) + vim.wo[win].winhl = "Normal:Normal,FloatBorder:Notify" .. notif_buf:level() + vim.wo[win].wrap = false + notif_buf:open(win) + return win +end + +return M diff --git a/lua/notify/util/init.lua b/lua/notify/util/init.lua index a663c98..c6ddbe9 100644 --- a/lua/notify/util/init.lua +++ b/lua/notify/util/init.lua @@ -1,5 +1,56 @@ local M = {} +function M.lazy_require(require_path) + return setmetatable({}, { + __call = function(_, ...) + return require(require_path)(...) + end, + __index = function(_, key) + return require(require_path)[key] + end, + __newindex = function(_, key, value) + require(require_path)[key] = value + end, + }) +end + +function M.pop(tbl, key, default) + local val = default + if tbl[key] then + val = tbl[key] + tbl[key] = nil + end + return val +end + +function M.crop(val, min, max) + return math.min(math.max(min, val), max) +end + +function M.zip(first, second) + local new = {} + for i, val in pairs(first) do + new[i] = { val, second[i] } + end + return new +end + +local function split_hex_colour(hex) + hex = hex:gsub("#", "") + return { tonumber(hex:sub(1, 2), 16), tonumber(hex:sub(3, 4), 16), tonumber(hex:sub(5, 6), 16) } +end + +function M.blend(fg_hex, bg_hex, alpha) + local channels = M.zip(split_hex_colour(fg_hex), split_hex_colour(bg_hex)) + + local blended = {} + for i, i_chans in pairs(channels) do + blended[i] = M.round(M.crop(alpha * i_chans[1] + (1 - alpha) * i_chans[2], 0, 255)) + end + + return string.format("#%02x%02x%02x", unpack(blended)) +end + function M.round(num, decimals) if decimals then return tonumber(string.format("%." .. decimals .. "f", num)) @@ -39,4 +90,74 @@ end M.FIFOQueue = require("notify.util.queue") +function M.rgb_to_numbers(s) + local colours = {} + for a in string.gmatch(s, "[A-Fa-f0-9][A-Fa-f0-9]") do + colours[#colours + 1] = tonumber(a, 16) + end + return colours +end + +function M.numbers_to_rgb(colours) + local colour = "#" + for _, num in pairs(colours) do + colour = colour .. string.format("%X", num) + end + return colour +end + +function M.deep_equal(t1, t2, ignore_mt) + local ty1 = type(t1) + local ty2 = type(t2) + + if ty1 ~= ty2 then + return false + end + if ty1 ~= "table" then + return t1 == t2 + end + + local mt = getmetatable(t1) + if not ignore_mt and mt and mt.__eq then + return t1 == t2 + end + + local checked + + for k1, v1 in pairs(t1) do + local v2 = t2[k1] + checked[k1] = true + if v2 == nil or not M.deep_equal(v1, v2, ignore_mt) then + return false + end + end + + for k2, _ in pairs(t2) do + if not checked[k2] then + return false + end + end + return true +end + +function M.update_configs(updates) + for win, win_updates in pairs(updates) do + local exists, conf = M.get_win_config(win) + if exists then + for _, field in pairs({ "row", "col", "height", "width" }) do + conf[field] = win_updates[field] or conf[field] + end + M.set_win_config(win, conf) + end + end +end + +function M.highlight(name, fields) + local fields_string = "" + for field, value in pairs(fields) do + fields_string = fields_string .. " " .. field .. "=" .. value + end + vim.cmd("hi " .. name .. fields_string) +end + return M diff --git a/lua/notify/windows/init.lua b/lua/notify/windows/init.lua new file mode 100644 index 0000000..2467254 --- /dev/null +++ b/lua/notify/windows/init.lua @@ -0,0 +1,224 @@ +local config = require("notify.config") +local api = vim.api +local animate = require("notify.animate") +local util = require("notify.util") + +---@class WindowAnimator +---@field pending FIFOQueue +---@field win_states table> +---@field win_stages table +---@field notif_bufs table +---@field timed table +---@field stages table +local WindowAnimator = {} + +function WindowAnimator:new(stages) + local animator = { + win_stages = {}, + win_states = {}, + notif_bufs = {}, + timed = {}, + stages = stages, + } + self.__index = self + setmetatable(animator, self) + return animator +end + +function WindowAnimator:render(queue, time) + self:push_pending(queue) + if vim.tbl_isempty(self.win_stages) then + return nil + end + local goals = self:get_goals() + self:update_states(time, goals) + self:advance_stages(goals) + return self:apply_updates() +end + +function WindowAnimator:push_pending(queue) + if queue:is_empty() then + return + end + while not queue:is_empty() do + ---@type NotificationBuf + local notif_buf = queue:peek() + local windows = vim.tbl_keys(self.win_stages) + local win_opts = self.stages[1]({ + message = { height = notif_buf:height(), width = notif_buf:width() }, + open_windows = windows, + }) + if not win_opts then + return + end + local opacity = util.pop(win_opts, "opacity", 100) + notif_buf.highlights:set_opacity(opacity) + local win = api.nvim_open_win(notif_buf:buffer(), false, win_opts) + vim.wo[win].winhl = "Normal:Normal,FloatBorder:" .. notif_buf.highlights.border + vim.wo[win].wrap = false + self.win_stages[win] = 2 + self.notif_bufs[win] = notif_buf + notif_buf:open(win) + queue:pop() + end +end + +function WindowAnimator:advance_stages(goals) + local default_complete = function(goal, position) + return goal == util.round(position, 2) + end + for win, _ in pairs(self.win_stages) do + local win_goals = goals[win] + local complete = true + for field, state in pairs(self.win_states[win] or {}) do + if win_goals[field].complete then + complete = win_goals[field].complete(state.position) + else + complete = default_complete(state.goal, state.position) + end + if not complete then + break + end + end + if complete then + self:advance_stage(win) + end + end +end + +function WindowAnimator:advance_stage(win) + local cur_stage = self.win_stages[win] + if self.timed[win] or not cur_stage then + return + end + if cur_stage < #self.stages then + if api.nvim_get_current_win() == win then + return + end + self.win_stages[win] = cur_stage + 1 + return + end + + self.win_stages[win] = nil + + local function close() + if api.nvim_get_current_win() == win then + return vim.defer_fn(close, 1000) + end + self:remove_win(win) + end + + close() +end + +function WindowAnimator:remove_win(win) + pcall(api.nvim_win_close, win, true) + self.win_stages[win] = nil + self.win_states[win] = nil + local notif_buf = self.notif_bufs[win] + self.notif_bufs[win] = nil + notif_buf:close(win) +end + +function WindowAnimator:update_states(time, goals) + local updated_states = {} + + for win, win_goals in pairs(goals) do + if win_goals.time and not self.timed[win] then + self.timed[win] = true + local timer_func = function() + self.timed[win] = nil + local notif_buf = self.notif_bufs[win] + if notif_buf and notif_buf:should_stay() then + return + end + self:advance_stage(win) + end + vim.defer_fn(timer_func, self.notif_bufs[win]:timeout() or config.default_timeout()) + end + + updated_states[win] = self:stage_state(win, win_goals, time) + end + + self.win_states = updated_states +end + +function WindowAnimator:stage_state(win, goals, time) + local cur_state = self.win_states[win] or {} + + local exists, win_conf = util.get_win_config(win) + if not exists then + self:remove_win(win) + return + end + + local new_state = {} + for field, goal in pairs(goals) do + if field ~= "time" then + local goal_type = type(goal) + -- Handle spring goal + if goal_type == "table" and goal[1] then + local init_state = ( + field == "opacity" and self.notif_bufs[win].highlights:get_opacity() or win_conf[field] + ) + local cur_field_state = cur_state[field] or {} + new_state[field] = animate.spring(time, { + position = cur_field_state.position or init_state, + velocity = cur_field_state.velocity, + goal = goal[1], + }, { + frequency = goal.frequency or 1, + damping = goal.damping or 1, + }) + --- Directly move goal + elseif goal_type ~= "table" then + new_state[field] = { position = goal } + else + print("nvim-notify: Invalid stage goal: " .. vim.inspect(goal)) + end + end + end + return new_state +end + +function WindowAnimator:get_goals() + local goals = {} + local open_windows = vim.tbl_keys(self.win_stages) + for win, win_stage in pairs(self.win_stages) do + local notif_buf = self.notif_bufs[win] + local win_goals = self.stages[win_stage]({ + message = { height = notif_buf:height(), width = notif_buf:width() }, + open_windows = open_windows, + }, win) + if not win_goals then + self:remove_win(win) + else + goals[win] = win_goals + end + end + return goals +end + +function WindowAnimator:apply_updates() + local updates = {} + for win, states in pairs(self.win_states) do + updates[win] = {} + for field, state in pairs(states) do + if field == "opacity" then + self.notif_bufs[win].highlights:set_opacity(state.position) + else + updates[win][field] = state.position + end + end + end + if vim.tbl_isempty(updates) then + return false + end + util.update_configs(updates) + return true +end + +---@return WindowAnimator +return function(stages) + return WindowAnimator:new(stages) +end