----------------------------------------------------------------------------
--- 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
--- 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
local escape_pattern = "[<>&]"
local escape_subs = { ['<'] = "<", ['>'] = ">", ['&'] = "&" }
-- 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 }
-- 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
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
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)
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
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