naughty: Don't interpret markup in titles
[awesome.git] / lib / naughty.lua.in
blob228ff175214e62cea2aebcfc6a596dbcdf02ba95
1 ----------------------------------------------------------------------------
2 -- @author koniu <gkusnierz@gmail.com>
3 -- @copyright 2008 koniu
4 -- @release @AWESOME_VERSION@
5 ----------------------------------------------------------------------------
7 -- Package environment
8 local pairs = pairs
9 local table = table
10 local type = type
11 local string = string
12 local tostring = tostring
13 local pcall = pcall
14 local capi = { screen = screen,
15 awesome = awesome,
16 dbus = dbus,
17 timer = timer,
18 awesome = awesome }
19 local button = require("awful.button")
20 local util = require("awful.util")
21 local bt = require("beautiful")
22 local wibox = require("wibox")
23 local surface = require("gears.surface")
24 local cairo = require("lgi").cairo
26 local schar = string.char
27 local sbyte = string.byte
28 local tcat = table.concat
29 local tins = table.insert
31 --- Notification library
32 local naughty = {}
34 --- Naughty configuration - a table containing common popup settings.
35 naughty.config = {}
36 --- Space between popups and edge of the workarea. Default: 4
37 naughty.config.padding = 4
38 --- Spacing between popups. Default: 1
39 naughty.config.spacing = 1
40 --- List of directories that will be checked by getIcon()
41 -- Default: { "/usr/share/pixmaps/", }
42 naughty.config.icon_dirs = { "/usr/share/pixmaps/", }
43 --- List of formats that will be checked by getIcon()
44 -- Default: { "png", "gif" }
45 naughty.config.icon_formats = { "png", "gif" }
46 --- Callback used to modify or reject notifications.
47 -- Default: nil
48 -- Example:
49 -- naughty.config.notify_callback = function(args)
50 -- args.text = 'prefix: ' .. args.text
51 -- return args
52 -- end
53 naughty.config.notify_callback = nil
56 --- Notification Presets - a table containing presets for different purposes
57 -- Preset is a table of any parameters available to notify(), overriding default
58 -- values (@see defaults)
59 -- You have to pass a reference of a preset in your notify() call to use the preset
60 -- The presets "low", "normal" and "critical" are used for notifications over DBUS
61 -- @field low The preset for notifications with low urgency level
62 -- @field normal The default preset for every notification without a preset that will also be used for normal urgency level
63 -- @field critical The preset for notifications with a critical urgency level
64 -- @class table
65 naughty.config.presets = {
66 normal = {},
67 low = {
68 timeout = 5
70 critical = {
71 bg = "#ff0000",
72 fg = "#ffffff",
73 timeout = 0,
77 --- Default values for the params to notify().
78 -- These can optionally be overridden by specifying a preset
79 -- @see naughty.config.presets
80 -- @see naughty.notify
81 naughty.config.defaults = {
82 timeout = 5,
83 text = "",
84 screen = 1,
85 ontop = true,
86 margin = "5",
87 border_width = "1",
88 position = "top_right"
91 -- DBUS Notification constants
92 local urgency = {
93 low = "\0",
94 normal = "\1",
95 critical = "\2"
98 --- DBUS notification to preset mapping
99 -- The first element is an object containing the filter
100 -- If the rules in the filter matches the associated preset will be applied
101 -- The rules object can contain: urgency, category, appname
102 -- The second element is the preset
104 naughty.config.mapping = {
105 {{urgency = urgency.low}, naughty.config.presets.low},
106 {{urgency = urgency.normal}, naughty.config.presets.normal},
107 {{urgency = urgency.critical}, naughty.config.presets.critical}
110 -- Counter for the notifications
111 -- Required for later access via DBUS
112 local counter = 1
114 -- True if notifying is suspended
115 local suspended = false
117 --- Index of notifications per screen and position. See config table for valid
118 -- 'position' values. Each element is a table consisting of:
119 -- @field box Wibox object containing the popup
120 -- @field height Popup height
121 -- @field width Popup width
122 -- @field die Function to be executed on timeout
123 -- @field id Unique notification id based on a counter
124 naughty.notifications = { suspended = { } }
125 for s = 1, capi.screen.count() do
126 naughty.notifications[s] = {
127 top_left = {},
128 top_right = {},
129 bottom_left = {},
130 bottom_right = {},
134 --- Suspend notifications
135 function naughty.suspend()
136 suspended = true
139 --- Resume notifications
140 function naughty.resume()
141 suspended = false
142 for i, v in pairs(naughty.notifications.suspended) do
143 v.box.visible = true
144 if v.timer then v.timer:start() end
146 naughty.notifications.suspended = { }
149 --- Toggle notification state
150 function naughty.toggle()
151 if suspended then
152 naughty.resume()
153 else
154 naughty.suspend()
158 -- Evaluate desired position of the notification by index - internal
159 -- @param idx Index of the notification
160 -- @param position top_right | top_left | bottom_right | bottom_left
161 -- @param height Popup height
162 -- @param width Popup width (optional)
163 -- @return Absolute position and index in { x = X, y = Y, idx = I } table
164 local function get_offset(screen, position, idx, width, height)
165 local ws = capi.screen[screen].workarea
166 local v = {}
167 local idx = idx or #naughty.notifications[screen][position] + 1
168 local width = width or naughty.notifications[screen][position][idx].width
170 -- calculate x
171 if position:match("left") then
172 v.x = ws.x + naughty.config.padding
173 else
174 v.x = ws.x + ws.width - (width + naughty.config.padding)
177 -- calculate existing popups' height
178 local existing = 0
179 for i = 1, idx-1, 1 do
180 existing = existing + naughty.notifications[screen][position][i].height + naughty.config.spacing
183 -- calculate y
184 if position:match("top") then
185 v.y = ws.y + naughty.config.padding + existing
186 else
187 v.y = ws.y + ws.height - (naughty.config.padding + height + existing)
190 -- if positioned outside workarea, destroy oldest popup and recalculate
191 if v.y + height > ws.y + ws.height or v.y < ws.y then
192 idx = idx - 1
193 naughty.destroy(naughty.notifications[screen][position][1])
194 v = get_offset(screen, position, idx, width, height)
196 if not v.idx then v.idx = idx end
198 return v
201 -- Re-arrange notifications according to their position and index - internal
202 -- @return None
203 local function arrange(screen)
204 for p,pos in pairs(naughty.notifications[screen]) do
205 for i,notification in pairs(naughty.notifications[screen][p]) do
206 local offset = get_offset(screen, p, i, notification.width, notification.height)
207 notification.box:geometry({ x = offset.x, y = offset.y })
208 notification.idx = offset.idx
213 --- Destroy notification by notification object
214 -- @param notification Notification object to be destroyed
215 -- @return True if the popup was successfully destroyed, nil otherwise
216 function naughty.destroy(notification)
217 if notification and notification.box.visible then
218 if suspended then
219 for k, v in pairs(naughty.notifications.suspended) do
220 if v.box == notification.box then
221 table.remove(naughty.notifications.suspended, k)
222 break
226 local scr = notification.screen
227 table.remove(naughty.notifications[scr][notification.position], notification.idx)
228 if notification.timer then
229 notification.timer:stop()
231 notification.box.visible = false
232 arrange(scr)
233 return true
237 -- Get notification by ID
238 -- @param id ID of the notification
239 -- @return notification object if it was found, nil otherwise
240 local function getById(id)
241 -- iterate the notifications to get the notfications with the correct ID
242 for s = 1, capi.screen.count() do
243 for p,pos in pairs(naughty.notifications[s]) do
244 for i,notification in pairs(naughty.notifications[s][p]) do
245 if notification.id == id then
246 return notification
253 --- Create notification. args is a dictionary of (optional) arguments.
254 -- @param text Text of the notification. Default: ''
255 -- @param title Title of the notification. Default: nil
256 -- @param timeout Time in seconds after which popup expires.
257 -- Set 0 for no timeout. Default: 5
258 -- @param hover_timeout Delay in seconds after which hovered popup disappears.
259 -- Default: nil
260 -- @param screen Target screen for the notification. Default: 1
261 -- @param position Corner of the workarea displaying the popups.
262 -- Values: "top_right" (default), "top_left", "bottom_left", "bottom_right".
263 -- @param ontop Boolean forcing popups to display on top. Default: true
264 -- @param height Popup height. Default: nil (auto)
265 -- @param width Popup width. Default: nil (auto)
266 -- @param font Notification font. Default: beautiful.font or awesome.font
267 -- @param icon Path to icon. Default: nil
268 -- @param icon_size Desired icon size in px. Default: nil
269 -- @param fg Foreground color. Default: beautiful.fg_focus or '#ffffff'
270 -- @param bg Background color. Default: beautiful.bg_focus or '#535d6c'
271 -- @param border_width Border width. Default: 1
272 -- @param border_color Border color.
273 -- Default: beautiful.border_focus or '#535d6c'
274 -- @param run Function to run on left click. Default: nil
275 -- @param preset Table with any of the above parameters. Note: Any parameters
276 -- specified directly in args will override ones defined in the preset.
277 -- @param replaces_id Replace the notification with the given ID
278 -- @param callback function that will be called with all arguments
279 -- the notification will only be displayed if the function returns true
280 -- note: this function is only relevant to notifications sent via dbus
281 -- @usage naughty.notify({ title = "Achtung!", text = "You're idling", timeout = 0 })
282 -- @return The notification object
283 function naughty.notify(args)
284 if naughty.config.notify_callback then
285 args = naughty.config.notify_callback(args)
286 if not args then return end
289 -- gather variables together
290 local preset = util.table.join(naughty.config.defaults or {},
291 args.preset or naughty.config.presets.normal or {})
292 local timeout = args.timeout or preset.timeout
293 local icon = args.icon or preset.icon
294 local icon_size = args.icon_size or preset.icon_size
295 local text = args.text or preset.text
296 local title = args.title or preset.title
297 local screen = args.screen or preset.screen
298 local ontop = args.ontop or preset.ontop
299 local width = args.width or preset.width
300 local height = args.height or preset.height
301 local hover_timeout = args.hover_timeout or preset.hover_timeout
302 local opacity = args.opacity or preset.opacity
303 local margin = args.margin or preset.margin
304 local border_width = args.border_width or preset.border_width
305 local position = args.position or preset.position
306 local escape_pattern = "[<>&]"
307 local escape_subs = { ['<'] = "&lt;", ['>'] = "&gt;", ['&'] = "&amp;" }
309 -- beautiful
310 local beautiful = bt.get()
311 local font = args.font or preset.font or beautiful.font or capi.awesome.font
312 local fg = args.fg or preset.fg or beautiful.fg_normal or '#ffffff'
313 local bg = args.bg or preset.bg or beautiful.bg_normal or '#535d6c'
314 local border_color = args.border_color or preset.border_color or beautiful.bg_focus or '#535d6c'
315 local notification = { screen = screen }
317 -- replace notification if needed
318 if args.replaces_id then
319 local obj = getById(args.replaces_id)
320 if obj then
321 -- destroy this and ...
322 naughty.destroy(obj)
324 -- ... may use its ID
325 if args.replaces_id <= counter then
326 notification.id = args.replaces_id
327 else
328 counter = counter + 1
329 notification.id = counter
331 else
332 -- get a brand new ID
333 counter = counter + 1
334 notification.id = counter
337 notification.position = position
339 if title then title = title .. "\n" else title = "" end
341 -- hook destroy
342 local die = function () naughty.destroy(notification) end
343 if timeout > 0 then
344 local timer_die = capi.timer { timeout = timeout }
345 timer_die:connect_signal("timeout", die)
346 if not suspended then
347 timer_die:start()
349 notification.timer = timer_die
351 notification.die = die
353 local run = function ()
354 if args.run then
355 args.run(notification)
356 else
357 die()
361 local hover_destroy = function ()
362 if hover_timeout == 0 then
363 die()
364 else
365 if notification.timer then notification.timer:stop() end
366 notification.timer = capi.timer { timeout = hover_timeout }
367 notification.timer:connect_signal("timeout", die)
368 notification.timer:start()
372 -- create textbox
373 local textbox = wibox.widget.textbox()
374 local marginbox = wibox.layout.margin()
375 marginbox:set_margins(margin)
376 marginbox:set_widget(textbox)
377 textbox:set_valign("middle")
378 textbox:set_font(font)
380 local function setMarkup(pattern, replacements)
381 textbox:set_markup(string.format('<b>%s</b>%s', title, text:gsub(pattern, replacements)))
383 local function setText()
384 textbox:set_text(string.format('%s %s', title, text))
387 -- Since the title cannot contain markup, it must be escaped first so that
388 -- it is not interpreted by Pango later.
389 title = title:gsub(escape_pattern, escape_subs)
390 -- Try to set the text while only interpreting <br>.
391 -- (Setting a textbox' .text to an invalid pattern throws a lua error)
392 if not pcall(setMarkup, "<br.->", "\n") then
393 -- That failed, escape everything which might cause an error from pango
394 if not pcall(setMarkup, escape_pattern, escape_subs) then
395 -- Ok, just ignore all pango markup. If this fails, we got some invalid utf8
396 if not pcall(setText) then
397 textbox:set_markup("<i>&lt;Invalid markup or UTF8, cannot display message&gt;</i>")
402 -- create iconbox
403 local iconbox = nil
404 local iconmargin = nil
405 local icon_w, icon_h = 0, 0
406 if icon then
407 -- try to guess icon if the provided one is non-existent/readable
408 if type(icon) == "string" and not util.file_readable(icon) then
409 icon = util.geticonpath(icon, naughty.config.icon_formats, naughty.config.icon_dirs, icon_size) or icon
412 -- is the icon file readable?
413 local success, res = pcall(function() return surface.load(icon) end)
414 if success then
415 icon = res
416 else
417 io.stderr:write(string.format("naughty: Couldn't load image '%s': %s\n", tostring(icon), res))
418 icon = nil
421 -- if we have an icon, use it
422 if icon then
423 iconbox = wibox.widget.imagebox()
424 iconmargin = wibox.layout.margin(iconbox, margin, margin, margin, margin)
425 if icon_size then
426 local scaled = cairo.ImageSurface(cairo.Format.ARGB32, icon_size, icon_size)
427 local cr = cairo.Context(scaled)
428 cr:scale(icon_size / icon:get_height(), icon_size / icon:get_width())
429 cr:set_source_surface(icon, 0, 0)
430 cr:paint()
431 icon = scaled
433 iconbox:set_resize(false)
434 iconbox:set_image(icon)
435 icon_w = icon:get_width()
436 icon_h = icon:get_height()
440 -- create container wibox
441 notification.box = wibox({ fg = fg,
442 bg = bg,
443 border_color = border_color,
444 border_width = border_width,
445 type = "notification" })
447 if hover_timeout then notification.box:connect_signal("mouse::enter", hover_destroy) end
449 -- calculate the height
450 if not height then
451 local w, h = textbox:fit(-1, -1)
452 if iconbox and icon_h + 2 * margin > h + 2 * margin then
453 height = icon_h + 2 * margin
454 else
455 height = h + 2 * margin
459 -- calculate the width
460 if not width then
461 local w, h = textbox:fit(-1, -1)
462 width = w + (iconbox and icon_w + 2 * margin or 0) + 2 * margin
465 -- crop to workarea size if too big
466 local workarea = capi.screen[screen].workarea
467 if width > workarea.width - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0) then
468 width = workarea.width - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0)
470 if height > workarea.height - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0) then
471 height = workarea.height - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0)
474 -- set size in notification object
475 notification.height = height + 2 * (border_width or 0)
476 notification.width = width + 2 * (border_width or 0)
478 -- position the wibox
479 local offset = get_offset(screen, notification.position, nil, notification.width, notification.height)
480 notification.box.ontop = ontop
481 notification.box:geometry({ width = width,
482 height = height,
483 x = offset.x,
484 y = offset.y })
485 notification.box.opacity = opacity
486 notification.box.visible = true
487 notification.idx = offset.idx
489 -- populate widgets
490 local layout = wibox.layout.fixed.horizontal()
491 if iconmargin then
492 layout:add(iconmargin)
494 layout:add(marginbox)
495 notification.box:set_widget(layout)
497 -- Setup the mouse events
498 layout:buttons(util.table.join(button({ }, 1, run), button({ }, 3, die)))
500 -- insert the notification to the table
501 table.insert(naughty.notifications[screen][notification.position], notification)
503 if suspended then
504 notification.box.visible = false
505 table.insert(naughty.notifications.suspended, notification)
508 -- return the notification
509 return notification
512 -- DBUS/Notification support
513 -- Notify
514 if capi.dbus then
515 capi.dbus.connect_signal("org.freedesktop.Notifications", function (data, appname, replaces_id, icon, title, text, actions, hints, expire)
516 local args = { }
517 if data.member == "Notify" then
518 if text ~= "" then
519 args.text = text
520 if title ~= "" then
521 args.title = title
523 else
524 if title ~= "" then
525 args.text = title
526 else
527 return
530 if appname ~= "" then
531 args.appname = appname
533 for i, obj in pairs(naughty.config.mapping) do
534 local filter, preset, s = obj[1], obj[2], 0
535 if (not filter.urgency or filter.urgency == hints.urgency) and
536 (not filter.category or filter.category == hints.category) and
537 (not filter.appname or filter.appname == appname) then
538 args.preset = util.table.join(args.preset, preset)
541 local preset = args.preset or naughty.config.defaults
542 if not preset.callback or (type(preset.callback) == "function" and
543 preset.callback(data, appname, replaces_id, icon, title, text, actions, hints, expire)) then
544 if icon ~= "" then
545 args.icon = icon
546 elseif hints.icon_data or hints.image_data then
547 if hints.icon_data == nil then hints.icon_data = hints.image_data end
549 -- icon_data is an array:
550 -- 1 -> width
551 -- 2 -> height
552 -- 3 -> rowstride
553 -- 4 -> has alpha
554 -- 5 -> bits per sample
555 -- 6 -> channels
556 -- 7 -> data
557 local w, h, rowstride, _, _, channels, data = unpack(hints.icon_data)
559 local format = cairo.Format[channels == 4 and 'ARGB32' or 'RGB24']
561 -- Figure out some stride magic (cairo dictates rowstride)
562 local stride = cairo.Format.stride_for_width(format, w)
563 local append = schar(0):rep(stride - 4 * w)
564 local offset = 0
566 -- Now convert each row on its own
567 local rows = {}
569 for y = 1, h do
570 local this_row = {}
572 for i = 1 + offset, w * channels + offset, channels do
573 local R, G, B, A = sbyte(data, i, i + channels - 1)
574 tins(this_row, schar(B, G, R, A or 255))
577 -- Handle rowstride, offset is stride for the input, append for output
578 tins(this_row, append)
579 tins(rows, tcat(this_row))
581 offset = offset + rowstride
584 args.icon = cairo.ImageSurface.create_for_data(tcat(rows), format,
585 w, h, stride)
587 if replaces_id and replaces_id ~= "" and replaces_id ~= 0 then
588 args.replaces_id = replaces_id
590 if expire and expire > -1 then
591 args.timeout = expire / 1000
593 local id = naughty.notify(args).id
594 return "u", id
596 return "u", "0"
597 elseif data.member == "CloseNotification" then
598 local obj = getById(appname)
599 if obj then
600 naughty.destroy(obj)
602 elseif data.member == "GetServerInfo" or data.member == "GetServerInformation" then
603 -- name of notification app, name of vender, version
604 return "s", "naughty", "s", "awesome", "s", capi.awesome.version:match("%d.%d"), "s", "1.0"
605 elseif data.member == "GetCapabilities" then
606 -- We actually do display the body of the message, we support <b>, <i>
607 -- and <u> in the body and we handle static (non-animated) icons.
608 return "as", { "s", "body", "s", "body-markup", "s", "icon-static" }
610 end)
612 capi.dbus.connect_signal("org.freedesktop.DBus.Introspectable",
613 function (data, text)
614 if data.member == "Introspect" then
615 local xml = [=[<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object
616 Introspection 1.0//EN"
617 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
618 <node>
619 <interface name="org.freedesktop.DBus.Introspectable">
620 <method name="Introspect">
621 <arg name="data" direction="out" type="s"/>
622 </method>
623 </interface>
624 <interface name="org.freedesktop.Notifications">
625 <method name="GetCapabilities">
626 <arg name="caps" type="as" direction="out"/>
627 </method>
628 <method name="CloseNotification">
629 <arg name="id" type="u" direction="in"/>
630 </method>
631 <method name="Notify">
632 <arg name="app_name" type="s" direction="in"/>
633 <arg name="id" type="u" direction="in"/>
634 <arg name="icon" type="s" direction="in"/>
635 <arg name="summary" type="s" direction="in"/>
636 <arg name="body" type="s" direction="in"/>
637 <arg name="actions" type="as" direction="in"/>
638 <arg name="hints" type="a{sv}" direction="in"/>
639 <arg name="timeout" type="i" direction="in"/>
640 <arg name="return_id" type="u" direction="out"/>
641 </method>
642 <method name="GetServerInformation">
643 <arg name="return_name" type="s" direction="out"/>
644 <arg name="return_vendor" type="s" direction="out"/>
645 <arg name="return_version" type="s" direction="out"/>
646 <arg name="return_spec_version" type="s" direction="out"/>
647 </method>
648 <method name="GetServerInfo">
649 <arg name="return_name" type="s" direction="out"/>
650 <arg name="return_vendor" type="s" direction="out"/>
651 <arg name="return_version" type="s" direction="out"/>
652 </method>
653 </interface>
654 </node>]=]
655 return "s", xml
657 end)
659 -- listen for dbus notification requests
660 capi.dbus.request_name("session", "org.freedesktop.Notifications")
663 return naughty
665 -- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80