naughty: Correctly handle rowstride on icons
[awesome.git] / lib / naughty.lua.in
blobf79172b1566fa36455ac44bf7be3ac3026a54cbf
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 pcall = pcall
13 local capi = { screen = screen,
14 awesome = awesome,
15 dbus = dbus,
16 timer = timer,
17 awesome = awesome }
18 local button = require("awful.button")
19 local util = require("awful.util")
20 local bt = require("beautiful")
21 local wibox = require("wibox")
22 local surface = require("gears.surface")
23 local cairo = require("lgi").cairo
25 --- Notification library
26 local naughty = {}
28 --- Naughty configuration - a table containing common popup settings.
29 -- @name config
30 -- @field padding Space between popups and edge of the workarea. Default: 4
31 -- @field spacing Spacing between popups. Default: 1
32 -- @field icon_dirs List of directories that will be checked by getIcon()
33 -- Default: { "/usr/share/pixmaps/", }
34 -- @field icon_formats List of formats that will be checked by getIcon()
35 -- Default: { "png", "gif" }
36 -- @field notify_callback Callback used to modify or reject notifications.
37 -- Default: nil
38 -- Example:
39 -- naughty.config.notify_callback = function(args)
40 -- args.text = 'prefix: ' .. args.text
41 -- return args
42 -- end
43 -- @class table
45 naughty.config = {}
46 naughty.config.padding = 4
47 naughty.config.spacing = 1
48 naughty.config.icon_dirs = { "/usr/share/pixmaps/", }
49 naughty.config.icon_formats = { "png", "gif" }
50 naughty.config.notify_callback = nil
53 --- Notification Presets - a table containing presets for different purposes
54 -- Preset is a table of any parameters available to notify(), overriding default
55 -- values (@see defaults)
56 -- You have to pass a reference of a preset in your notify() call to use the preset
57 -- The presets "low", "normal" and "critical" are used for notifications over DBUS
58 -- @name config.presets
59 -- @field low The preset for notifications with low urgency level
60 -- @field normal The default preset for every notification without a preset that will also be used for normal urgency level
61 -- @field critical The preset for notifications with a critical urgency level
62 -- @class table
64 naughty.config.presets = {
65 normal = {},
66 low = {
67 timeout = 5
69 critical = {
70 bg = "#ff0000",
71 fg = "#ffffff",
72 timeout = 0,
76 --- Default values for the params to notify().
77 -- These can optionally be overridden by specifying a preset
78 -- @see config.presets
79 -- @see notify
80 naughty.config.defaults = {
81 timeout = 5,
82 text = "",
83 screen = 1,
84 ontop = true,
85 margin = "5",
86 border_width = "1",
87 position = "top_right"
90 -- DBUS Notification constants
91 local urgency = {
92 low = "\0",
93 normal = "\1",
94 critical = "\2"
97 --- DBUS notification to preset mapping
98 -- @name config.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. See config table for valid 'position' values.
118 -- 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 -- @name notifications[screen][position]
125 -- @class table
127 local notifications = { suspended = { } }
128 for s = 1, capi.screen.count() do
129 notifications[s] = {
130 top_left = {},
131 top_right = {},
132 bottom_left = {},
133 bottom_right = {},
137 --- Suspend notifications
138 function naughty.suspend()
139 suspended = true
142 --- Resume notifications
143 function naughty.resume()
144 suspended = false
145 for i, v in pairs(notifications.suspended) do
146 v.box.visible = true
147 if v.timer then v.timer:start() end
149 notifications.suspended = { }
152 --- Toggle notification state
153 function naughty.toggle()
154 if suspended then
155 naughty.resume()
156 else
157 naughty.suspend()
161 -- Evaluate desired position of the notification by index - internal
162 -- @param idx Index of the notification
163 -- @param position top_right | top_left | bottom_right | bottom_left
164 -- @param height Popup height
165 -- @param width Popup width (optional)
166 -- @return Absolute position and index in { x = X, y = Y, idx = I } table
167 local function get_offset(screen, position, idx, width, height)
168 local ws = capi.screen[screen].workarea
169 local v = {}
170 local idx = idx or #notifications[screen][position] + 1
171 local width = width or notifications[screen][position][idx].width
173 -- calculate x
174 if position:match("left") then
175 v.x = ws.x + naughty.config.padding
176 else
177 v.x = ws.x + ws.width - (width + naughty.config.padding)
180 -- calculate existing popups' height
181 local existing = 0
182 for i = 1, idx-1, 1 do
183 existing = existing + notifications[screen][position][i].height + naughty.config.spacing
186 -- calculate y
187 if position:match("top") then
188 v.y = ws.y + naughty.config.padding + existing
189 else
190 v.y = ws.y + ws.height - (naughty.config.padding + height + existing)
193 -- if positioned outside workarea, destroy oldest popup and recalculate
194 if v.y + height > ws.y + ws.height or v.y < ws.y then
195 idx = idx - 1
196 naughty.destroy(notifications[screen][position][1])
197 v = get_offset(screen, position, idx, width, height)
199 if not v.idx then v.idx = idx end
201 return v
204 -- Re-arrange notifications according to their position and index - internal
205 -- @return None
206 local function arrange(screen)
207 for p,pos in pairs(notifications[screen]) do
208 for i,notification in pairs(notifications[screen][p]) do
209 local offset = get_offset(screen, p, i, notification.width, notification.height)
210 notification.box:geometry({ x = offset.x, y = offset.y })
211 notification.idx = offset.idx
216 --- Destroy notification by notification object
217 -- @param notification Notification object to be destroyed
218 -- @return True if the popup was successfully destroyed, nil otherwise
219 function naughty.destroy(notification)
220 if notification and notification.box.visible then
221 if suspended then
222 for k, v in pairs(notifications.suspended) do
223 if v.box == notification.box then
224 table.remove(notifications.suspended, k)
225 break
229 local scr = notification.screen
230 table.remove(notifications[scr][notification.position], notification.idx)
231 if notification.timer then
232 notification.timer:stop()
234 notification.box.visible = false
235 arrange(scr)
236 return true
240 -- Get notification by ID
241 -- @param id ID of the notification
242 -- @return notification object if it was found, nil otherwise
243 local function getById(id)
244 -- iterate the notifications to get the notfications with the correct ID
245 for s = 1, capi.screen.count() do
246 for p,pos in pairs(notifications[s]) do
247 for i,notification in pairs(notifications[s][p]) do
248 if notification.id == id then
249 return notification
256 --- Create notification. args is a dictionary of (optional) arguments.
257 -- @param text Text of the notification. Default: ''
258 -- @param title Title of the notification. Default: nil
259 -- @param timeout Time in seconds after which popup expires.
260 -- Set 0 for no timeout. Default: 5
261 -- @param hover_timeout Delay in seconds after which hovered popup disappears.
262 -- Default: nil
263 -- @param screen Target screen for the notification. Default: 1
264 -- @param position Corner of the workarea displaying the popups.
265 -- Values: "top_right" (default), "top_left", "bottom_left", "bottom_right".
266 -- @param ontop Boolean forcing popups to display on top. Default: true
267 -- @param height Popup height. Default: nil (auto)
268 -- @param width Popup width. Default: nil (auto)
269 -- @param font Notification font. Default: beautiful.font or awesome.font
270 -- @param icon Path to icon. Default: nil
271 -- @param icon_size Desired icon size in px. Default: nil
272 -- @param fg Foreground color. Default: beautiful.fg_focus or '#ffffff'
273 -- @param bg Background color. Default: beautiful.bg_focus or '#535d6c'
274 -- @param border_width Border width. Default: 1
275 -- @param border_color Border color.
276 -- Default: beautiful.border_focus or '#535d6c'
277 -- @param run Function to run on left click. Default: nil
278 -- @param preset Table with any of the above parameters. Note: Any parameters
279 -- specified directly in args will override ones defined in the preset.
280 -- @param replaces_id Replace the notification with the given ID
281 -- @param callback function that will be called with all arguments
282 -- the notification will only be displayed if the function returns true
283 -- note: this function is only relevant to notifications sent via dbus
284 -- @usage naughty.notify({ title = "Achtung!", text = "You're idling", timeout = 0 })
285 -- @return The notification object
286 function naughty.notify(args)
287 if naughty.config.notify_callback then
288 args = naughty.config.notify_callback(args)
289 if not args then return end
292 -- gather variables together
293 local preset = util.table.join(naughty.config.defaults or {},
294 args.preset or naughty.config.presets.normal or {})
295 local timeout = args.timeout or preset.timeout
296 local icon = args.icon or preset.icon
297 local icon_size = args.icon_size or preset.icon_size
298 local text = args.text or preset.text
299 local title = args.title or preset.title
300 local screen = args.screen or preset.screen
301 local ontop = args.ontop or preset.ontop
302 local width = args.width or preset.width
303 local height = args.height or preset.height
304 local hover_timeout = args.hover_timeout or preset.hover_timeout
305 local opacity = args.opacity or preset.opacity
306 local margin = args.margin or preset.margin
307 local border_width = args.border_width or preset.border_width
308 local position = args.position or preset.position
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:gsub(pattern, replacements), text:gsub(pattern, replacements)))
384 local function setText()
385 textbox:set_text(string.format('%s %s', title, text))
388 -- First try to set the text while only interpreting <br>.
389 -- (Setting a textbox' .text to an invalid pattern throws a lua error)
390 if not pcall(setMarkup, "<br.->", "\n") then
391 -- That failed, escape everything which might cause an error from pango
392 if not pcall(setMarkup, "[<>&]", { ['<'] = "&lt;", ['>'] = "&gt;", ['&'] = "&amp;" }) then
393 -- Ok, just ignore all pango markup. If this fails, we got some invalid utf8
394 if not pcall(setText) then
395 textbox:set_markup("<i>&lt;Invalid markup or UTF8, cannot display message&gt;</i>")
400 -- create iconbox
401 local iconbox = nil
402 local iconmargin = nil
403 local icon_w, icon_h = 0, 0
404 if icon then
405 -- try to guess icon if the provided one is non-existent/readable
406 if type(icon) == "string" and not util.file_readable(icon) then
407 icon = util.geticonpath(icon, naughty.config.icon_formats, naughty.config.icon_dirs, icon_size)
410 -- if we have an icon, use it
411 if icon then
412 iconbox = wibox.widget.imagebox()
413 iconmargin = wibox.layout.margin(iconbox, margin, margin, margin, margin)
414 local img = surface.load(icon)
415 if icon_size then
416 local scaled = cairo.ImageSurface(cairo.Format.ARGB32, icon_size, icon_size)
417 local cr = cairo.Context(scaled)
418 cr:scale(icon_size / img:get_height(), icon_size / img:get_width())
419 cr:set_source_surface(img, 0, 0)
420 cr:paint()
421 img = scaled
423 iconbox:set_resize(false)
424 iconbox:set_image(img)
425 icon_w = img:get_width()
426 icon_h = img:get_height()
430 -- create container wibox
431 notification.box = wibox({ fg = fg,
432 bg = bg,
433 border_color = border_color,
434 border_width = border_width,
435 type = "notification" })
437 if hover_timeout then notification.box:connect_signal("mouse::enter", hover_destroy) end
439 -- calculate the height
440 if not height then
441 local w, h = textbox:fit(-1, -1)
442 if iconbox and icon_h + 2 * margin > h + 2 * margin then
443 height = icon_h + 2 * margin
444 else
445 height = h + 2 * margin
449 -- calculate the width
450 if not width then
451 local w, h = textbox:fit(-1, -1)
452 width = w + (iconbox and icon_w + 2 * margin or 0) + 2 * margin
455 -- crop to workarea size if too big
456 local workarea = capi.screen[screen].workarea
457 if width > workarea.width - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0) then
458 width = workarea.width - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0)
460 if height > workarea.height - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0) then
461 height = workarea.height - 2 * (border_width or 0) - 2 * (naughty.config.padding or 0)
464 -- set size in notification object
465 notification.height = height + 2 * (border_width or 0)
466 notification.width = width + 2 * (border_width or 0)
468 -- position the wibox
469 local offset = get_offset(screen, notification.position, nil, notification.width, notification.height)
470 notification.box.ontop = ontop
471 notification.box:geometry({ width = width,
472 height = height,
473 x = offset.x,
474 y = offset.y })
475 notification.box.opacity = opacity
476 notification.box.visible = true
477 notification.idx = offset.idx
479 -- populate widgets
480 local layout = wibox.layout.fixed.horizontal()
481 if iconmargin then
482 layout:add(iconmargin)
484 layout:add(marginbox)
485 notification.box:set_widget(layout)
487 -- Setup the mouse events
488 layout:buttons(util.table.join(button({ }, 1, run), button({ }, 3, die)))
490 -- insert the notification to the table
491 table.insert(notifications[screen][notification.position], notification)
493 if suspended then
494 notification.box.visible = false
495 table.insert(notifications.suspended, notification)
498 -- return the notification
499 return notification
502 -- DBUS/Notification support
503 -- Notify
504 if capi.dbus then
505 capi.dbus.connect_signal("org.freedesktop.Notifications", function (data, appname, replaces_id, icon, title, text, actions, hints, expire)
506 local args = { }
507 if data.member == "Notify" then
508 if text ~= "" then
509 args.text = text
510 if title ~= "" then
511 args.title = title
513 else
514 if title ~= "" then
515 args.text = title
516 else
517 return
520 if appname ~= "" then
521 args.appname = appname
523 for i, obj in pairs(naughty.config.mapping) do
524 local filter, preset, s = obj[1], obj[2], 0
525 if (not filter.urgency or filter.urgency == hints.urgency) and
526 (not filter.category or filter.category == hints.category) and
527 (not filter.appname or filter.appname == appname) then
528 args.preset = util.table.join(args.preset, preset)
531 local preset = args.preset or naughty.config.defaults
532 if not preset.callback or (type(preset.callback) == "function" and
533 preset.callback(data, appname, replaces_id, icon, title, text, actions, hints, expire)) then
534 if icon ~= "" then
535 args.icon = icon
536 elseif hints.icon_data or hints.image_data then
537 if hints.icon_data == nil then hints.icon_data = hints.image_data end
538 -- icon_data is an array:
539 -- 1 -> width, 2 -> height, 3 -> rowstride, 4 -> has alpha
540 -- 5 -> bits per sample, 6 -> channels, 7 -> data
541 local width, height, rowstride = hints.icon_data[1], hints.icon_data[2], hints.icon_data[3]
543 local imgdata = ""
544 local format, bpp
545 -- If has alpha (ARGB32)
546 if hints.icon_data[6] == 4 then
547 format, bpp = cairo.Format.ARGB32, 4
548 -- If has not alpha (RGB24)
549 elseif hints.icon_data[6] == 3 then
550 format, bpp = cairo.Format.RGB24, 3
552 -- Figure out some stride magic (cairo dictates rowstride)
553 local stride = cairo.Format.stride_for_width(format, width)
554 local append = string.format("%c", 0):rep(stride - 4 * width)
555 local offset = 0
556 -- Now convert each row on its own
557 for y = 1, height do
558 for i = 1 + offset, width * bpp + offset, bpp do
559 imgdata = imgdata .. hints.icon_data[7]:sub(i, i + 2):reverse()
560 if bpp == 4 then
561 imgdata = imgdata .. hints.icon_data[7]:sub(i + 3, i + 3)
562 else
563 imgdata = imgdata .. string.format("%c", 255)
566 -- Handle rowstride, offset is stride for the input, append for output
567 offset = offset + rowstride
568 imgdata = imgdata .. append
570 args.icon = cairo.ImageSurface.create_for_data(imgdata, format,
571 width, height, stride)
573 if replaces_id and replaces_id ~= "" and replaces_id ~= 0 then
574 args.replaces_id = replaces_id
576 if expire and expire > -1 then
577 args.timeout = expire / 1000
579 local id = naughty.notify(args).id
580 return "u", id
582 return "u", "0"
583 elseif data.member == "CloseNotification" then
584 local obj = getById(appname)
585 if obj then
586 naughty.destroy(obj)
588 elseif data.member == "GetServerInfo" or data.member == "GetServerInformation" then
589 -- name of notification app, name of vender, version
590 return "s", "naughty", "s", "awesome", "s", capi.awesome.version:match("%d.%d"), "s", "1.0"
591 elseif data.member == "GetCapabilities" then
592 -- We actually do display the body of the message, we support <b>, <i>
593 -- and <u> in the body and we handle static (non-animated) icons.
594 return "as", { "s", "body", "s", "body-markup", "s", "icon-static" }
596 end)
598 capi.dbus.connect_signal("org.freedesktop.DBus.Introspectable",
599 function (data, text)
600 if data.member == "Introspect" then
601 local xml = [=[<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object
602 Introspection 1.0//EN"
603 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
604 <node>
605 <interface name="org.freedesktop.DBus.Introspectable">
606 <method name="Introspect">
607 <arg name="data" direction="out" type="s"/>
608 </method>
609 </interface>
610 <interface name="org.freedesktop.Notifications">
611 <method name="GetCapabilities">
612 <arg name="caps" type="as" direction="out"/>
613 </method>
614 <method name="CloseNotification">
615 <arg name="id" type="u" direction="in"/>
616 </method>
617 <method name="Notify">
618 <arg name="app_name" type="s" direction="in"/>
619 <arg name="id" type="u" direction="in"/>
620 <arg name="icon" type="s" direction="in"/>
621 <arg name="summary" type="s" direction="in"/>
622 <arg name="body" type="s" direction="in"/>
623 <arg name="actions" type="as" direction="in"/>
624 <arg name="hints" type="a{sv}" direction="in"/>
625 <arg name="timeout" type="i" direction="in"/>
626 <arg name="return_id" type="u" direction="out"/>
627 </method>
628 <method name="GetServerInformation">
629 <arg name="return_name" type="s" direction="out"/>
630 <arg name="return_vendor" type="s" direction="out"/>
631 <arg name="return_version" type="s" direction="out"/>
632 <arg name="return_spec_version" type="s" direction="out"/>
633 </method>
634 <method name="GetServerInfo">
635 <arg name="return_name" type="s" direction="out"/>
636 <arg name="return_vendor" type="s" direction="out"/>
637 <arg name="return_version" type="s" direction="out"/>
638 </method>
639 </interface>
640 </node>]=]
641 return "s", xml
643 end)
645 -- listen for dbus notification requests
646 capi.dbus.request_name("session", "org.freedesktop.Notifications")
649 return naughty
651 -- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80