---------------------------------------------------------------------------- --- Notification library -- -- @author koniu <gkusnierz@gmail.com> -- @copyright 2008 koniu -- @release @AWESOME_VERSION@ -- @module naughty ---------------------------------------------------------------------------- -- Package environment local pairs = pairs local table = table local type = type local string = string local tostring = tostring local pcall = pcall local capi = { screen = screen, awesome = awesome } local timer = require("gears.timer") local button = require("awful.button") local util = require("awful.util") local bt = require("beautiful") local wibox = require("wibox") local surface = require("gears.surface") local cairo = require("lgi").cairo local schar = string.char local sbyte = string.byte local tcat = table.concat local tins = table.insert local naughty = {} --[[-- Naughty configuration - a table containing common popup settings. @table naughty.config @field padding Space between popups and edge of the workarea. Default: `4` @field spacing Spacing between popups. Default: `1` @field icon_dirs List of directories that will be checked by `getIcon()`. Default: `{ "/usr/share/pixmaps/", }` @field icon_formats List of formats that will be checked by `getIcon()`. Default: `{ "png", "gif" }` @field notify_callback Callback used to modify or reject notifications. Default: `nil` naughty.config.notify_callback = function(args) args.text = 'prefix: ' .. args.text return args end @field presets Notification Presets - a table containing presets for different purposes. Preset is a table of any parameters available to `notify()`, overriding default values (`naughty.config.defaults`) You have to pass a reference of a preset in your notify() call to use the preset The presets `"low"`, `"normal"` and `"critical"` are used for notifications over DBUS. @field presets.low The preset for notifications with low urgency level. @field presets.normal The default preset for every notification without a preset that will also be used for normal urgency level. @field presets.critical The preset for notifications with a critical urgency level. @field defaults Default values for the params to `notify()`. These can optionally be overridden by specifying a preset. @field mapping DBUS notification to preset mapping. The first element is an object containing the filter If the rules in the filter matches the associated preset will be applied The rules object can contain: urgency, category, appname The second element is the preset @field mapping.1 low urgency @field mapping.2 normal urgency @field mapping.3 critical urgency --]] -- naughty.config = { padding = 4, spacing = 1, icon_dirs = { "/usr/share/pixmaps/", }, icon_formats = { "png", "gif" }, notify_callback = nil, } naughty.config.presets = { low = { timeout = 5 }, normal = {}, critical = { bg = "#ff0000", fg = "#ffffff", timeout = 0, } } naughty.config.defaults = { timeout = 5, text = "", screen = 1, ontop = true, margin = "5", border_width = "1", position = "top_right" } naughty.notificationClosedReason = { silent = -1, expired = 1, dismissedByUser = 2, dismissedByCommand = 3, undefined = 4 } -- Counter for the notifications -- Required for later access via DBUS local counter = 1 -- True if notifying is suspended local suspended = false --- Index of notifications per screen and position. -- See config table for valid 'position' values. -- Each element is a table consisting of: -- -- @field box Wibox object containing the popup -- @field height Popup height -- @field width Popup width -- @field die Function to be executed on timeout -- @field id Unique notification id based on a counter -- @table notifications naughty.notifications = { suspended = { } } for s = 1, capi.screen.count() do naughty.notifications[s] = { top_left = {}, top_middle = {}, top_right = {}, bottom_left = {}, bottom_middle = {}, bottom_right = {}, } end --- Suspend notifications function naughty.suspend() suspended = true end --- Resume notifications function naughty.resume() suspended = false for i, v in pairs(naughty.notifications.suspended) do v.box.visible = true if v.timer then v.timer:start() end end naughty.notifications.suspended = { } end --- Toggle notification state function naughty.toggle() if suspended then naughty.resume() else naughty.suspend() end end --- Evaluate desired position of the notification by index - internal -- -- @param screen Screen to use -- @param position top_right | top_left | bottom_right | bottom_left -- | top_middle | bottom_middle -- @param idx Index of the notification -- @param[opt] width Popup width. -- @param height Popup height -- @return Absolute position and index in { x = X, y = Y, idx = I } table local function get_offset(screen, position, idx, width, height) local ws = capi.screen[screen].workarea local v = {} local idx = idx or #naughty.notifications[screen][position] + 1 local width = width or naughty.notifications[screen][position][idx].width -- calculate x if position:match("left") then v.x = ws.x + naughty.config.padding elseif position:match("middle") then v.x = (ws.width / 2) - (width / 2) else v.x = ws.x + ws.width - (width + naughty.config.padding) end -- calculate existing popups' height local existing = 0 for i = 1, idx-1, 1 do existing = existing + naughty.notifications[screen][position][i].height + naughty.config.spacing end -- calculate y if position:match("top") then v.y = ws.y + naughty.config.padding + existing else v.y = ws.y + ws.height - (naughty.config.padding + height + existing) end -- if positioned outside workarea, destroy oldest popup and recalculate if v.y + height > ws.y + ws.height or v.y < ws.y then idx = idx - 1 naughty.destroy(naughty.notifications[screen][position][1]) v = get_offset(screen, position, idx, width, height) end if not v.idx then v.idx = idx end return v end --- Re-arrange notifications according to their position and index - internal -- -- @return None local function arrange(screen) for p,pos in pairs(naughty.notifications[screen]) do for i,notification in pairs(naughty.notifications[screen][p]) do local offset = get_offset(screen, p, i, notification.width, notification.height) notification.box:geometry({ x = offset.x, y = offset.y }) notification.idx = offset.idx end end end --- Destroy notification by notification object -- -- @param notification Notification object to be destroyed -- @param reason One of the reasons from notificationClosedReason -- @return True if the popup was successfully destroyed, nil otherwise function naughty.destroy(notification, reason) if notification and notification.box.visible then if suspended then for k, v in pairs(naughty.notifications.suspended) do if v.box == notification.box then table.remove(naughty.notifications.suspended, k) break end end end local scr = notification.screen table.remove(naughty.notifications[scr][notification.position], notification.idx) if notification.timer then notification.timer:stop() end notification.box.visible = false arrange(scr) if notification.destroy_cb and reason ~= naughty.notificationClosedReason.silent then notification.destroy_cb(reason or naughty.notificationClosedReason.undefined) end return true end end --- Get notification by ID -- -- @param id ID of the notification -- @return notification object if it was found, nil otherwise function naughty.getById(id) -- iterate the notifications to get the notfications with the correct ID for s = 1, capi.screen.count() do for p,pos in pairs(naughty.notifications[s]) do for i,notification in pairs(naughty.notifications[s][p]) do if notification.id == id then return notification end end end end end --- Install expiration timer for notification object. -- @tparam notification notification Notification object. -- @tparam number timeout Time in seconds to be set as expiration timeout. local function set_timeout(notification, timeout) local die = function (reason) naughty.destroy(notification, reason) end if timeout > 0 then local timer_die = timer { timeout = timeout } timer_die:connect_signal("timeout", function() die(naughty.notificationClosedReason.expired) end) if not suspended then timer_die:start() end notification.timer = timer_die end notification.die = die end --- Set new notification timeout. -- @tparam notification notification Notification object, which timer is to be reset. -- @tparam number new_timeout Time in seconds after which notification disappears. -- @return None. function naughty.reset_timeout(notification, new_timeout) if notification.timer then notification.timer:stop() end local timeout = timeout or notification.timeout set_timeout(notification, timeout) notification.timeout = timeout notification.timer:start() end --- Escape and set title and text for notification object. -- @tparam notification notification Notification object. -- @tparam string title Title of notification. -- @tparam string text Main text of notification. -- @return None. local function set_text(notification, title, text) local escape_pattern = "[<>&]" local escape_subs = { ['<'] = "<", ['>'] = ">", ['&'] = "&" } local textbox = notification.textbox local function setMarkup(pattern, replacements) textbox:set_markup(string.format('%s%s', title, text:gsub(pattern, replacements))) end local function setText() textbox:set_text(string.format('%s %s', title, text)) end -- Since the title cannot contain markup, it must be escaped first so that -- it is not interpreted by Pango later. title = title:gsub(escape_pattern, escape_subs) -- Try to set the text while only interpreting
. -- (Setting a textbox' .text to an invalid pattern throws a lua error) if not pcall(setMarkup, "", "\n") then -- That failed, escape everything which might cause an error from pango if not pcall(setMarkup, escape_pattern, escape_subs) then -- Ok, just ignore all pango markup. If this fails, we got some invalid utf8 if not pcall(setText) then textbox:set_markup("<Invalid markup or UTF8, cannot display message>") end end end end --- Replace title and text of an existing notification. -- @tparam notification notification Notification object, which contents are to be replaced. -- @tparam string new_title New title of notification. If not specified, old title remains unchanged. -- @tparam string new_text New text of notification. If not specified, old text remains unchanged. -- @return None. function naughty.replace_text(notification, new_title, new_text) local title = new_title if title then title = title .. "\n" else title = "" end set_text(notification, title, new_text) end --- Create a notification. -- -- @tab args The argument table containing any of the arguments below. -- @string args.text Text of the notification. -- Default: '' -- @string args.title Title of the notification. -- Default: nil -- @int args.timeout Time in seconds after which popup expires. -- Set 0 for no timeout. -- Default: 5 -- @int args.hover_timeout Delay in seconds after which hovered popup disappears. -- Default: nil -- @int args.screen Target screen for the notification. -- Default: 1 -- @string args.position Corner of the workarea displaying the popups. -- Values: `"top_right"` (default), `"top_left"`, `"bottom_left"`, -- `"bottom_right"`, `"top_middle"`, `"bottom_middle"`. -- @bool args.ontop Boolean forcing popups to display on top. -- Default: true -- @int args.height Popup height. -- Default: nil (auto) -- @int args.width Popup width. -- Default: nil (auto) -- @string args.font Notification font. -- Default: beautiful.font or awesome.font -- @string args.icon Path to icon. -- Default: nil -- @int args.icon_size Desired icon size in px. -- Default: nil -- @string args.fg Foreground color. -- Default: `beautiful.fg_focus` or `'#ffffff'` -- @string args.bg Background color. -- Default: `beautiful.bg_focus` or `'#535d6c'` -- @int args.border_width Border width. -- Default: 1 -- @string args.border_color Border color. -- Default: `beautiful.border_focus` or `'#535d6c'` -- @tparam func args.run Function to run on left click. Default: nil -- @tparam func args.destroy Function to run when notification is destroyed. Default: nil. -- @tparam table args.preset Table with any of the above parameters. -- Note: Any parameters specified directly in args -- will override ones defined in the preset. -- @tparam int args.replaces_id Replace the notification with the given ID. -- @tparam func args.callback Function that will be called with all arguments. -- The notification will only be displayed if the -- function returns true. -- Note: this function is only relevant to -- notifications sent via dbus. -- @tparam table args.actions Mapping that maps a string to a callback when this -- action is selected. -- @usage naughty.notify({ title = "Achtung!", text = "You're idling", timeout = 0 }) -- @return The notification object function naughty.notify(args) if naughty.config.notify_callback then args = naughty.config.notify_callback(args) if not args then return end end -- gather variables together local preset = util.table.join(naughty.config.defaults or {}, args.preset or naughty.config.presets.normal or {}) local timeout = args.timeout or preset.timeout local icon = args.icon or preset.icon local icon_size = args.icon_size or preset.icon_size local text = args.text or preset.text local title = args.title or preset.title local screen = args.screen or preset.screen local ontop = args.ontop or preset.ontop local width = args.width or preset.width local height = args.height or preset.height local hover_timeout = args.hover_timeout or preset.hover_timeout local opacity = args.opacity or preset.opacity local margin = args.margin or preset.margin local border_width = args.border_width or preset.border_width local position = args.position or preset.position local actions = args.actions local destroy_cb = args.destroy -- beautiful local beautiful = bt.get() local font = args.font or preset.font or beautiful.font or capi.awesome.font local fg = args.fg or preset.fg or beautiful.fg_normal or '#ffffff' local bg = args.bg or preset.bg or beautiful.bg_normal or '#535d6c' local border_color = args.border_color or preset.border_color or beautiful.bg_focus or '#535d6c' local notification = { screen = screen, destroy_cb = destroy_cb, timeout = timeout } -- replace notification if needed if args.replaces_id then local obj = naughty.getById(args.replaces_id) if obj then -- destroy this and ... naughty.destroy(obj, naughty.notificationClosedReason.silent) end -- ... may use its ID if args.replaces_id <= counter then notification.id = args.replaces_id else counter = counter + 1 notification.id = counter end else -- get a brand new ID counter = counter + 1 notification.id = counter end notification.position = position if title then title = title .. "\n" else title = "" end -- hook destroy set_timeout(notification, timeout) local die = notification.die local run = function () if args.run then args.run(notification) else die(naughty.notificationClosedReason.dismissedByUser) end end local hover_destroy = function () if hover_timeout == 0 then die(naughty.notificationClosedReason.expired) else if notification.timer then notification.timer:stop() end notification.timer = timer { timeout = hover_timeout } notification.timer:connect_signal("timeout", function() die(naughty.notificationClosedReason.expired) end) notification.timer:start() end end -- create textbox local textbox = wibox.widget.textbox() local marginbox = wibox.layout.margin() marginbox:set_margins(margin) marginbox:set_widget(textbox) textbox:set_valign("middle") textbox:set_font(font) notification.textbox = textbox set_text(notification, title, text) local actionslayout = wibox.layout.fixed.vertical() local actions_max_width = 0 local actions_total_height = 0 if actions then for action, callback in pairs(actions) do local actiontextbox = wibox.widget.textbox() local actionmarginbox = wibox.layout.margin() actionmarginbox:set_margins(margin) actionmarginbox:set_widget(actiontextbox) actiontextbox:set_valign("middle") actiontextbox:set_font(font) actiontextbox:set_markup(string.format('%s', action)) -- calculate the height and width local w, h = actiontextbox:fit(-1, -1) local height = h + 2 * margin local width = w + 2 * margin actionmarginbox:buttons(util.table.join( button({ }, 1, callback), button({ }, 3, callback) )) actionslayout:add(actionmarginbox) actions_total_height = actions_total_height + height if actions_max_width < width then actions_max_width = width end end end -- create iconbox local iconbox = nil local iconmargin = nil local icon_w, icon_h = 0, 0 if icon then -- Is this really an URI instead of a path? if type(icon) == "string" and string.sub(icon, 1, 7) == "file://" then icon = string.sub(icon, 8) end -- try to guess icon if the provided one is non-existent/readable if type(icon) == "string" and not util.file_readable(icon) then icon = util.geticonpath(icon, naughty.config.icon_formats, naughty.config.icon_dirs, icon_size) or icon end -- is the icon file readable? local success, res = pcall(function() return surface.load_uncached(icon) end) if success then icon = res else io.stderr:write(string.format("naughty: Couldn't load image '%s': %s\n", tostring(icon), res)) icon = nil end -- if we have an icon, use it if icon then iconbox = wibox.widget.imagebox() iconmargin = wibox.layout.margin(iconbox, margin, margin, margin, margin) if icon_size then local scaled = cairo.ImageSurface(cairo.Format.ARGB32, icon_size, icon_size) local cr = cairo.Context(scaled) cr:scale(icon_size / icon:get_height(), icon_size / icon:get_width()) cr:set_source_surface(icon, 0, 0) cr:paint() icon = scaled end iconbox:set_resize(false) iconbox:set_image(icon) icon_w = icon:get_width() icon_h = icon:get_height() end end -- create container wibox notification.box = wibox({ fg = fg, bg = bg, border_color = border_color, border_width = border_width, type = "notification" }) if hover_timeout then notification.box:connect_signal("mouse::enter", hover_destroy) end -- calculate the height if not height then local w, h = textbox:fit(-1, -1) if iconbox and icon_h + 2 * margin > h + 2 * margin then height = icon_h + 2 * margin else height = h + 2 * margin end end height = height + actions_total_height -- calculate the width if not width then local w, h = textbox:fit(-1, -1) width = w + (iconbox and icon_w + 2 * margin or 0) + 2 * margin end if width < actions_max_width then width = actions_max_width end -- crop to workarea size if too big local workarea = capi.screen[screen].workarea if width > workarea.width - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0) then width = workarea.width - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0) end if height > workarea.height - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0) then height = workarea.height - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0) end -- set size in notification object notification.height = height + 2 * (border_width or 0) notification.width = width + 2 * (border_width or 0) -- position the wibox local offset = get_offset(screen, notification.position, nil, notification.width, notification.height) notification.box.ontop = ontop notification.box:geometry({ width = width, height = height, x = offset.x, y = offset.y }) notification.box.opacity = opacity notification.box.visible = true notification.idx = offset.idx -- populate widgets local layout = wibox.layout.fixed.horizontal() if iconmargin then layout:add(iconmargin) end layout:add(marginbox) local completelayout = wibox.layout.fixed.vertical() completelayout:add(layout) completelayout:add(actionslayout) notification.box:set_widget(completelayout) -- Setup the mouse events layout:buttons(util.table.join(button({ }, 1, run), button({ }, 3, function() die(naughty.notificationClosedReason.dismissedByUser) end))) -- insert the notification to the table table.insert(naughty.notifications[screen][notification.position], notification) if suspended then notification.box.visible = false table.insert(naughty.notifications.suspended, notification) end -- return the notification return notification end return naughty -- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80