tag: Improve tag property::index support (FS#1229)
[awesome.git] / lib / naughty.lua.in
blob6c3528e5b88de0d158a2154389558afad01233a8
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 -- @table naughty.config.presets
65 naughty.config.presets = {
66 low = {
67 timeout = 5
69 normal = {},
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 -- @table naughty.notifications
125 naughty.notifications = { suspended = { } }
126 for s = 1, capi.screen.count() do
127 naughty.notifications[s] = {
128 top_left = {},
129 top_right = {},
130 bottom_left = {},
131 bottom_right = {},
135 --- Suspend notifications
136 function naughty.suspend()
137 suspended = true
140 --- Resume notifications
141 function naughty.resume()
142 suspended = false
143 for i, v in pairs(naughty.notifications.suspended) do
144 v.box.visible = true
145 if v.timer then v.timer:start() end
147 naughty.notifications.suspended = { }
150 --- Toggle notification state
151 function naughty.toggle()
152 if suspended then
153 naughty.resume()
154 else
155 naughty.suspend()
159 -- Evaluate desired position of the notification by index - internal
160 -- @param idx Index of the notification
161 -- @param position top_right | top_left | bottom_right | bottom_left
162 -- @param height Popup height
163 -- @param width Popup width (optional)
164 -- @return Absolute position and index in { x = X, y = Y, idx = I } table
165 local function get_offset(screen, position, idx, width, height)
166 local ws = capi.screen[screen].workarea
167 local v = {}
168 local idx = idx or #naughty.notifications[screen][position] + 1
169 local width = width or naughty.notifications[screen][position][idx].width
171 -- calculate x
172 if position:match("left") then
173 v.x = ws.x + naughty.config.padding
174 else
175 v.x = ws.x + ws.width - (width + naughty.config.padding)
178 -- calculate existing popups' height
179 local existing = 0
180 for i = 1, idx-1, 1 do
181 existing = existing + naughty.notifications[screen][position][i].height + naughty.config.spacing
184 -- calculate y
185 if position:match("top") then
186 v.y = ws.y + naughty.config.padding + existing
187 else
188 v.y = ws.y + ws.height - (naughty.config.padding + height + existing)
191 -- if positioned outside workarea, destroy oldest popup and recalculate
192 if v.y + height > ws.y + ws.height or v.y < ws.y then
193 idx = idx - 1
194 naughty.destroy(naughty.notifications[screen][position][1])
195 v = get_offset(screen, position, idx, width, height)
197 if not v.idx then v.idx = idx end
199 return v
202 -- Re-arrange notifications according to their position and index - internal
203 -- @return None
204 local function arrange(screen)
205 for p,pos in pairs(naughty.notifications[screen]) do
206 for i,notification in pairs(naughty.notifications[screen][p]) do
207 local offset = get_offset(screen, p, i, notification.width, notification.height)
208 notification.box:geometry({ x = offset.x, y = offset.y })
209 notification.idx = offset.idx
214 --- Destroy notification by notification object
215 -- @param notification Notification object to be destroyed
216 -- @return True if the popup was successfully destroyed, nil otherwise
217 function naughty.destroy(notification)
218 if notification and notification.box.visible then
219 if suspended then
220 for k, v in pairs(naughty.notifications.suspended) do
221 if v.box == notification.box then
222 table.remove(naughty.notifications.suspended, k)
223 break
227 local scr = notification.screen
228 table.remove(naughty.notifications[scr][notification.position], notification.idx)
229 if notification.timer then
230 notification.timer:stop()
232 notification.box.visible = false
233 arrange(scr)
234 return true
238 -- Get notification by ID
239 -- @param id ID of the notification
240 -- @return notification object if it was found, nil otherwise
241 local function getById(id)
242 -- iterate the notifications to get the notfications with the correct ID
243 for s = 1, capi.screen.count() do
244 for p,pos in pairs(naughty.notifications[s]) do
245 for i,notification in pairs(naughty.notifications[s][p]) do
246 if notification.id == id then
247 return notification
254 --- Create notification. args is a dictionary of (optional) arguments.
255 -- @param text Text of the notification. Default: ''
256 -- @param title Title of the notification. Default: nil
257 -- @param timeout Time in seconds after which popup expires.
258 -- Set 0 for no timeout. Default: 5
259 -- @param hover_timeout Delay in seconds after which hovered popup disappears.
260 -- Default: nil
261 -- @param screen Target screen for the notification. Default: 1
262 -- @param position Corner of the workarea displaying the popups.
263 -- Values: "top_right" (default), "top_left", "bottom_left", "bottom_right".
264 -- @param ontop Boolean forcing popups to display on top. Default: true
265 -- @param height Popup height. Default: nil (auto)
266 -- @param width Popup width. Default: nil (auto)
267 -- @param font Notification font. Default: beautiful.font or awesome.font
268 -- @param icon Path to icon. Default: nil
269 -- @param icon_size Desired icon size in px. Default: nil
270 -- @param fg Foreground color. Default: beautiful.fg_focus or '#ffffff'
271 -- @param bg Background color. Default: beautiful.bg_focus or '#535d6c'
272 -- @param border_width Border width. Default: 1
273 -- @param border_color Border color.
274 -- Default: beautiful.border_focus or '#535d6c'
275 -- @param run Function to run on left click. Default: nil
276 -- @param preset Table with any of the above parameters. Note: Any parameters
277 -- specified directly in args will override ones defined in the preset.
278 -- @param replaces_id Replace the notification with the given ID
279 -- @param callback function that will be called with all arguments
280 -- the notification will only be displayed if the function returns true
281 -- note: this function is only relevant to notifications sent via dbus
282 -- @usage naughty.notify({ title = "Achtung!", text = "You're idling", timeout = 0 })
283 -- @return The notification object
284 function naughty.notify(args)
285 if naughty.config.notify_callback then
286 args = naughty.config.notify_callback(args)
287 if not args then return end
290 -- gather variables together
291 local preset = util.table.join(naughty.config.defaults or {},
292 args.preset or naughty.config.presets.normal or {})
293 local timeout = args.timeout or preset.timeout
294 local icon = args.icon or preset.icon
295 local icon_size = args.icon_size or preset.icon_size
296 local text = args.text or preset.text
297 local title = args.title or preset.title
298 local screen = args.screen or preset.screen
299 local ontop = args.ontop or preset.ontop
300 local width = args.width or preset.width
301 local height = args.height or preset.height
302 local hover_timeout = args.hover_timeout or preset.hover_timeout
303 local opacity = args.opacity or preset.opacity
304 local margin = args.margin or preset.margin
305 local border_width = args.border_width or preset.border_width
306 local position = args.position or preset.position
307 local escape_pattern = "[<>&]"
308 local escape_subs = { ['<'] = "&lt;", ['>'] = "&gt;", ['&'] = "&amp;" }
310 -- beautiful
311 local beautiful = bt.get()
312 local font = args.font or preset.font or beautiful.font or capi.awesome.font
313 local fg = args.fg or preset.fg or beautiful.fg_normal or '#ffffff'
314 local bg = args.bg or preset.bg or beautiful.bg_normal or '#535d6c'
315 local border_color = args.border_color or preset.border_color or beautiful.bg_focus or '#535d6c'
316 local notification = { screen = screen }
318 -- replace notification if needed
319 if args.replaces_id then
320 local obj = getById(args.replaces_id)
321 if obj then
322 -- destroy this and ...
323 naughty.destroy(obj)
325 -- ... may use its ID
326 if args.replaces_id <= counter then
327 notification.id = args.replaces_id
328 else
329 counter = counter + 1
330 notification.id = counter
332 else
333 -- get a brand new ID
334 counter = counter + 1
335 notification.id = counter
338 notification.position = position
340 if title then title = title .. "\n" else title = "" end
342 -- hook destroy
343 local die = function () naughty.destroy(notification) end
344 if timeout > 0 then
345 local timer_die = capi.timer { timeout = timeout }
346 timer_die:connect_signal("timeout", die)
347 if not suspended then
348 timer_die:start()
350 notification.timer = timer_die
352 notification.die = die
354 local run = function ()
355 if args.run then
356 args.run(notification)
357 else
358 die()
362 local hover_destroy = function ()
363 if hover_timeout == 0 then
364 die()
365 else
366 if notification.timer then notification.timer:stop() end
367 notification.timer = capi.timer { timeout = hover_timeout }
368 notification.timer:connect_signal("timeout", die)
369 notification.timer:start()
373 -- create textbox
374 local textbox = wibox.widget.textbox()
375 local marginbox = wibox.layout.margin()
376 marginbox:set_margins(margin)
377 marginbox:set_widget(textbox)
378 textbox:set_valign("middle")
379 textbox:set_font(font)
381 local function setMarkup(pattern, replacements)
382 textbox:set_markup(string.format('<b>%s</b>%s', title, text:gsub(pattern, replacements)))
384 local function setText()
385 textbox:set_text(string.format('%s %s', title, text))
388 -- Since the title cannot contain markup, it must be escaped first so that
389 -- it is not interpreted by Pango later.
390 title = title:gsub(escape_pattern, escape_subs)
391 -- Try to set the text while only interpreting <br>.
392 -- (Setting a textbox' .text to an invalid pattern throws a lua error)
393 if not pcall(setMarkup, "<br.->", "\n") then
394 -- That failed, escape everything which might cause an error from pango
395 if not pcall(setMarkup, escape_pattern, escape_subs) then
396 -- Ok, just ignore all pango markup. If this fails, we got some invalid utf8
397 if not pcall(setText) then
398 textbox:set_markup("<i>&lt;Invalid markup or UTF8, cannot display message&gt;</i>")
403 -- create iconbox
404 local iconbox = nil
405 local iconmargin = nil
406 local icon_w, icon_h = 0, 0
407 if icon then
408 -- Is this really an URI instead of a path?
409 if type(icon) == "string" and string.sub(icon, 1, 7) == "file://" then
410 icon = string.sub(icon, 8)
412 -- try to guess icon if the provided one is non-existent/readable
413 if type(icon) == "string" and not util.file_readable(icon) then
414 icon = util.geticonpath(icon, naughty.config.icon_formats, naughty.config.icon_dirs, icon_size) or icon
417 -- is the icon file readable?
418 local success, res = pcall(function() return surface.load_uncached(icon) end)
419 if success then
420 icon = res
421 else
422 io.stderr:write(string.format("naughty: Couldn't load image '%s': %s\n", tostring(icon), res))
423 icon = nil
426 -- if we have an icon, use it
427 if icon then
428 iconbox = wibox.widget.imagebox()
429 iconmargin = wibox.layout.margin(iconbox, margin, margin, margin, margin)
430 if icon_size then
431 local scaled = cairo.ImageSurface(cairo.Format.ARGB32, icon_size, icon_size)
432 local cr = cairo.Context(scaled)
433 cr:scale(icon_size / icon:get_height(), icon_size / icon:get_width())
434 cr:set_source_surface(icon, 0, 0)
435 cr:paint()
436 icon = scaled
438 iconbox:set_resize(false)
439 iconbox:set_image(icon)
440 icon_w = icon:get_width()
441 icon_h = icon:get_height()
445 -- create container wibox
446 notification.box = wibox({ fg = fg,
447 bg = bg,
448 border_color = border_color,
449 border_width = border_width,
450 type = "notification" })
452 if hover_timeout then notification.box:connect_signal("mouse::enter", hover_destroy) end
454 -- calculate the height
455 if not height then
456 local w, h = textbox:fit(-1, -1)
457 if iconbox and icon_h + 2 * margin > h + 2 * margin then
458 height = icon_h + 2 * margin
459 else
460 height = h + 2 * margin
464 -- calculate the width
465 if not width then
466 local w, h = textbox:fit(-1, -1)
467 width = w + (iconbox and icon_w + 2 * margin or 0) + 2 * margin
470 -- crop to workarea size if too big
471 local workarea = capi.screen[screen].workarea
472 if width > workarea.width - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0) then
473 width = workarea.width - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0)
475 if height > workarea.height - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0) then
476 height = workarea.height - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0)
479 -- set size in notification object
480 notification.height = height + 2 * (border_width or 0)
481 notification.width = width + 2 * (border_width or 0)
483 -- position the wibox
484 local offset = get_offset(screen, notification.position, nil, notification.width, notification.height)
485 notification.box.ontop = ontop
486 notification.box:geometry({ width = width,
487 height = height,
488 x = offset.x,
489 y = offset.y })
490 notification.box.opacity = opacity
491 notification.box.visible = true
492 notification.idx = offset.idx
494 -- populate widgets
495 local layout = wibox.layout.fixed.horizontal()
496 if iconmargin then
497 layout:add(iconmargin)
499 layout:add(marginbox)
500 notification.box:set_widget(layout)
502 -- Setup the mouse events
503 layout:buttons(util.table.join(button({ }, 1, run), button({ }, 3, die)))
505 -- insert the notification to the table
506 table.insert(naughty.notifications[screen][notification.position], notification)
508 if suspended then
509 notification.box.visible = false
510 table.insert(naughty.notifications.suspended, notification)
513 -- return the notification
514 return notification
517 -- DBUS/Notification support
518 -- Notify
519 if capi.dbus then
520 capi.dbus.connect_signal("org.freedesktop.Notifications", function (data, appname, replaces_id, icon, title, text, actions, hints, expire)
521 local args = { }
522 if data.member == "Notify" then
523 if text ~= "" then
524 args.text = text
525 if title ~= "" then
526 args.title = title
528 else
529 if title ~= "" then
530 args.text = title
531 else
532 return
535 if appname ~= "" then
536 args.appname = appname
538 for i, obj in pairs(naughty.config.mapping) do
539 local filter, preset, s = obj[1], obj[2], 0
540 if (not filter.urgency or filter.urgency == hints.urgency) and
541 (not filter.category or filter.category == hints.category) and
542 (not filter.appname or filter.appname == appname) then
543 args.preset = util.table.join(args.preset, preset)
546 local preset = args.preset or naughty.config.defaults
547 if not preset.callback or (type(preset.callback) == "function" and
548 preset.callback(data, appname, replaces_id, icon, title, text, actions, hints, expire)) then
549 if icon ~= "" then
550 args.icon = icon
551 elseif hints.icon_data or hints.image_data then
552 if hints.icon_data == nil then hints.icon_data = hints.image_data end
554 -- icon_data is an array:
555 -- 1 -> width
556 -- 2 -> height
557 -- 3 -> rowstride
558 -- 4 -> has alpha
559 -- 5 -> bits per sample
560 -- 6 -> channels
561 -- 7 -> data
562 local w, h, rowstride, _, _, channels, data = unpack(hints.icon_data)
564 -- Do the arguments look sane? (e.g. we have enough data)
565 local expected_length = rowstride * (h - 1) + w * channels
566 if w < 0 or h < 0 or rowstride < 0 or (channels ~= 3 and channels ~= 4) or
567 string.len(data) < expected_length then
568 w = 0
569 h = 0
572 local format = cairo.Format[channels == 4 and 'ARGB32' or 'RGB24']
574 -- Figure out some stride magic (cairo dictates rowstride)
575 local stride = cairo.Format.stride_for_width(format, w)
576 local append = schar(0):rep(stride - 4 * w)
577 local offset = 0
579 -- Now convert each row on its own
580 local rows = {}
582 for y = 1, h do
583 local this_row = {}
585 for i = 1 + offset, w * channels + offset, channels do
586 local R, G, B, A = sbyte(data, i, i + channels - 1)
587 tins(this_row, schar(B, G, R, A or 255))
590 -- Handle rowstride, offset is stride for the input, append for output
591 tins(this_row, append)
592 tins(rows, tcat(this_row))
594 offset = offset + rowstride
597 args.icon = cairo.ImageSurface.create_for_data(tcat(rows), format,
598 w, h, stride)
600 if replaces_id and replaces_id ~= "" and replaces_id ~= 0 then
601 args.replaces_id = replaces_id
603 if expire and expire > -1 then
604 args.timeout = expire / 1000
606 local id = naughty.notify(args).id
607 return "u", id
609 return "u", "0"
610 elseif data.member == "CloseNotification" then
611 local obj = getById(appname)
612 if obj then
613 naughty.destroy(obj)
615 elseif data.member == "GetServerInfo" or data.member == "GetServerInformation" then
616 -- name of notification app, name of vender, version
617 return "s", "naughty", "s", "awesome", "s", capi.awesome.version:match("%d.%d"), "s", "1.0"
618 elseif data.member == "GetCapabilities" then
619 -- We actually do display the body of the message, we support <b>, <i>
620 -- and <u> in the body and we handle static (non-animated) icons.
621 return "as", { "s", "body", "s", "body-markup", "s", "icon-static" }
623 end)
625 capi.dbus.connect_signal("org.freedesktop.DBus.Introspectable",
626 function (data, text)
627 if data.member == "Introspect" then
628 local xml = [=[<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object
629 Introspection 1.0//EN"
630 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
631 <node>
632 <interface name="org.freedesktop.DBus.Introspectable">
633 <method name="Introspect">
634 <arg name="data" direction="out" type="s"/>
635 </method>
636 </interface>
637 <interface name="org.freedesktop.Notifications">
638 <method name="GetCapabilities">
639 <arg name="caps" type="as" direction="out"/>
640 </method>
641 <method name="CloseNotification">
642 <arg name="id" type="u" direction="in"/>
643 </method>
644 <method name="Notify">
645 <arg name="app_name" type="s" direction="in"/>
646 <arg name="id" type="u" direction="in"/>
647 <arg name="icon" type="s" direction="in"/>
648 <arg name="summary" type="s" direction="in"/>
649 <arg name="body" type="s" direction="in"/>
650 <arg name="actions" type="as" direction="in"/>
651 <arg name="hints" type="a{sv}" direction="in"/>
652 <arg name="timeout" type="i" direction="in"/>
653 <arg name="return_id" type="u" direction="out"/>
654 </method>
655 <method name="GetServerInformation">
656 <arg name="return_name" type="s" direction="out"/>
657 <arg name="return_vendor" type="s" direction="out"/>
658 <arg name="return_version" type="s" direction="out"/>
659 <arg name="return_spec_version" type="s" direction="out"/>
660 </method>
661 <method name="GetServerInfo">
662 <arg name="return_name" type="s" direction="out"/>
663 <arg name="return_vendor" type="s" direction="out"/>
664 <arg name="return_version" type="s" direction="out"/>
665 </method>
666 </interface>
667 </node>]=]
668 return "s", xml
670 end)
672 -- listen for dbus notification requests
673 capi.dbus.request_name("session", "org.freedesktop.Notifications")
676 return naughty
678 -- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80