From 9df77e5c765d1b523babcdbc6a45e6cbafebcc21 Mon Sep 17 00:00:00 2001 From: Emmanuel Lepage Vallee Date: Sat, 1 Jul 2017 22:43:26 -0400 Subject: [PATCH] naughty: Move the notification object into its own file. Mostly for the documentation, but also as the new base upon which to build the modular notification GUI. --- awesomerc.lua | 17 +- docs/config.ld | 3 + lib/awful/spawn.lua | 6 +- lib/naughty/constants.lua | 74 ++ lib/naughty/core.lua | 870 +++++------------- lib/naughty/dbus.lua | 21 +- lib/naughty/init.lua | 3 + lib/naughty/layout/init.lua | 9 + lib/naughty/layout/legacy.lua | 544 +++++++++++ lib/naughty/notification.lua | 416 +++++++++ .../text/awful/keygrabber/autostart.lua | 4 +- .../wibox/awidget/prompt/keypress.lua | 2 +- .../examples/wibox/awidget/prompt/simple.lua | 2 +- tests/test-screen-changes.lua | 2 +- 14 files changed, 1334 insertions(+), 639 deletions(-) create mode 100644 lib/naughty/constants.lua create mode 100644 lib/naughty/layout/init.lua create mode 100644 lib/naughty/layout/legacy.lua create mode 100644 lib/naughty/notification.lua diff --git a/awesomerc.lua b/awesomerc.lua index d3390c57..0bc0432c 100644 --- a/awesomerc.lua +++ b/awesomerc.lua @@ -24,9 +24,11 @@ require("awful.hotkeys_popup.keys") -- Check if awesome encountered an error during startup and fell back to -- another config (This code will only ever execute for the fallback config) if awesome.startup_errors then - naughty.notify({ preset = naughty.config.presets.critical, - title = "Oops, there were errors during startup!", - text = awesome.startup_errors }) + naughty.notification { + preset = naughty.config.presets.critical, + title = "Oops, there were errors during startup!", + text = awesome.startup_errors + } end -- Handle runtime errors after startup @@ -37,9 +39,12 @@ do if in_error then return end in_error = true - naughty.notify({ preset = naughty.config.presets.critical, - title = "Oops, an error happened!", - text = tostring(err) }) + naughty.notification { + preset = naughty.config.presets.critical, + title = "Oops, an error happened!", + text = tostring(err) + } + in_error = false end) end diff --git a/docs/config.ld b/docs/config.ld index 7e9164c3..5a264241 100644 --- a/docs/config.ld +++ b/docs/config.ld @@ -121,9 +121,12 @@ file = { '../lib/gears/init.lua', '../lib/wibox/layout/init.lua', '../lib/wibox/container/init.lua', + '../lib/naughty/constants.lua', + '../lib/naughty/dbus.lua', -- Ignore some parts of the widget library '../lib/awful/widget/init.lua', + '../lib/naughty/layout/init.lua', -- Deprecated classes for one years or more don't deserve entries -- in the index diff --git a/lib/awful/spawn.lua b/lib/awful/spawn.lua index 3ffb1f95..37b634c1 100644 --- a/lib/awful/spawn.lua +++ b/lib/awful/spawn.lua @@ -132,17 +132,17 @@ -- -- awful.spawn.with_line_callback(noisy, { -- stdout = function(line) --- naughty.notify { text = "LINE:"..line } +-- naughty.notification { text = "LINE:"..line } -- end, -- stderr = function(line) --- naughty.notify { text = "ERR:"..line} +-- naughty.notification { text = "ERR:"..line} -- end, -- }) -- -- If only the full output is needed, then `easy_async` is the right choice: -- -- awful.spawn.easy_async(noisy, function(stdout, stderr, reason, exit_code) --- naughty.notify { text = stdout } +-- naughty.notification { text = stdout } -- end) -- -- **Default applications**: diff --git a/lib/naughty/constants.lua b/lib/naughty/constants.lua new file mode 100644 index 00000000..aa7906bc --- /dev/null +++ b/lib/naughty/constants.lua @@ -0,0 +1,74 @@ +---------------------------------------------------------------------------- +--- This file hosts the shared constants used by the notification subsystem. +-- +-- [[documented in core.lua]] +-- +-- @author koniu <gkusnierz@gmail.com> +-- @author Emmanuel Lepage Vallee <elv1313@gmail.com> +-- @copyright 2008 koniu +-- @copyright 2017 Emmanuel Lepage Vallee +---------------------------------------------------------------------------- +local beautiful = require("beautiful") +local dpi = beautiful.xresources.apply_dpi + +local ret = {} + +ret.config = { + padding = dpi(4), + spacing = dpi(1), + icon_dirs = { "/usr/share/pixmaps/", }, + icon_formats = { "png", "gif" }, + notify_callback = nil, +} + +ret.config.presets = { + low = { + timeout = 5 + }, + normal = {}, + critical = { + bg = "#ff0000", + fg = "#ffffff", + timeout = 0, + }, + ok = { + bg = "#00bb00", + fg = "#ffffff", + timeout = 5, + }, + info = { + bg = "#0000ff", + fg = "#ffffff", + timeout = 5, + }, + warn = { + bg = "#ffaa00", + fg = "#000000", + timeout = 10, + }, +} + +ret.config.defaults = { + timeout = 5, + text = "", + screen = nil, + ontop = true, + margin = dpi(5), + border_width = dpi(1), + position = "top_right" +} + +ret.notification_closed_reason = { + silent = -1, + expired = 1, + dismissedByUser = 2, --TODO v5 remove this undocumented legacy constant + dismissed_by_user = 2, + dismissedByCommand = 3, --TODO v5 remove this undocumented legacy constant + dismissed_by_vommand = 3, + undefined = 4 +} + +-- Legacy --TODO v5 remove this alias +ret.notificationClosedReason = ret.notification_closed_reason + +return ret diff --git a/lib/naughty/core.lua b/lib/naughty/core.lua index 24398b4b..ea382740 100644 --- a/lib/naughty/core.lua +++ b/lib/naughty/core.lua @@ -9,67 +9,43 @@ --luacheck: no max line length -- Package environment -local pairs = pairs -local table = table -local type = type -local string = string -local pcall = pcall -local capi = { screen = screen, - awesome = awesome } -local timer = require("gears.timer") -local button = require("awful.button") +local capi = { screen = screen } +local gdebug = require("gears.debug") local screen = require("awful.screen") -local util = require("awful.util") local gtable = require("gears.table") -local gfs = require("gears.filesystem") -local beautiful = require("beautiful") -local wibox = require("wibox") -local surface = require("gears.surface") -local cairo = require("lgi").cairo -local dpi = beautiful.xresources.apply_dpi - -local function get_screen(s) - return s and capi.screen[s] -end local naughty = {} ---[[-- -Naughty configuration - a table containing common popup settings. - -@table naughty.config -@tfield[opt=apply_dpi(4)] int padding Space between popups and edge of the - workarea. -@tfield[opt=apply_dpi(1)] int spacing Spacing between popups. -@tfield[opt={"/usr/share/pixmaps/"}] table icon_dirs List of directories - that will be checked by `getIcon()`. -@tfield[opt={ "png", "gif" }] table icon_formats List of formats that will be - checked by `getIcon()`. -@tfield[opt] function notify_callback Callback used to modify or reject -notifications, e.g. - naughty.config.notify_callback = function(args) - args.text = 'prefix: ' .. args.text - return args - end - To reject a notification return `nil` from the callback. - If the notification is a freedesktop notification received via DBUS, you can - access the freedesktop hints via `args.freedesktop_hints` if any where - specified. - -@tfield table presets Notification presets. See `config.presets`. - -@tfield table defaults Default values for the params to `notify()`. These can - optionally be overridden by specifying a preset. See `config.defaults`. - ---]] +--- Naughty configuration - a table containing common popup settings. -- -naughty.config = { - padding = dpi(4), - spacing = dpi(1), - icon_dirs = { "/usr/share/pixmaps/", }, - icon_formats = { "png", "gif" }, - notify_callback = nil, -} +-- @table naughty.config +-- @tfield[opt=apply_dpi(4)] int padding Space between popups and edge of the +-- workarea. +-- @tfield[opt=apply_dpi(1)] int spacing Spacing between popups. +-- @tfield[opt={"/usr/share/pixmaps/"}] table icon_dirs List of directories +-- that will be checked by `getIcon()`. +-- @tfield[opt={ "png", "gif" }] table icon_formats List of formats that will be +-- checked by `getIcon()`. +-- @tfield[opt] function notify_callback Callback used to modify or reject +-- notifications, e.g. +-- naughty.config.notify_callback = function(args) +-- args.text = 'prefix: ' .. args.text +-- return args +-- end +-- To reject a notification return `nil` from the callback. +-- If the notification is a freedesktop notification received via DBUS, you can +-- access the freedesktop hints via `args.freedesktop_hints` if any where +-- specified. +-- +-- @tfield table presets Notification presets. See `config.presets`. +-- +-- @tfield table defaults Default values for the params to `notify()`. These can +-- optionally be overridden by specifying a preset. See `config.defaults`. + +-- It's done that way to preserve compatibility with Awesome 4.0 while allowing +-- the naughty submodules to use the contants without creating a circular +-- dependency. +gtable.crush(naughty, require("naughty.constants")) --- Notification presets for `naughty.notify`. -- This holds presets for different purposes. A preset is a table of any @@ -92,32 +68,6 @@ naughty.config = { -- @tfield[opt="#ff0000"] string critical.bg -- @tfield[opt="#ffffff"] string critical.fg -- @tfield[opt=0] string critical.timeout -naughty.config.presets = { - low = { - timeout = 5 - }, - normal = {}, - critical = { - bg = "#ff0000", - fg = "#ffffff", - timeout = 0, - }, - ok = { - bg = "#00bb00", - fg = "#ffffff", - timeout = 5, - }, - info = { - bg = "#0000ff", - fg = "#ffffff", - timeout = 5, - }, - warn = { - bg = "#ffaa00", - fg = "#000000", - timeout = 10, - }, -} --- Defaults for `naughty.notify`. -- @@ -129,78 +79,29 @@ naughty.config.presets = { -- @tfield[opt=apply_dpi(5)] int margin -- @tfield[opt=apply_dpi(1)] int border_width -- @tfield[opt="top_right"] string position -naughty.config.defaults = { - timeout = 5, - text = "", - screen = nil, - ontop = true, - margin = dpi(5), - border_width = dpi(1), - position = "top_right" -} -naughty.notificationClosedReason = { - silent = -1, - expired = 1, - dismissedByUser = 2, - dismissedByCommand = 3, - undefined = 4 -} +--- The reason why a notification is to be closed. +-- See [the specification](https://developer.gnome.org/notification-spec/#signals) +-- for more details. +-- @tfield number silent +-- @tfield number expired +-- @tfield number dismissed_by_user +-- @tfield number dismissed_by_command +-- @tfield number undefined +-- @table notification_closed_reason +--- The global suspension state. +-- +-- When suspended, no notification widget should interrupt the user. This is +-- useful when watching movies or doing presentations. +-- +-- @property suspended +-- @param boolean ---- Notifications font. --- @beautiful beautiful.notification_font --- @tparam string|lgi.Pango.FontDescription notification_font - ---- Notifications background color. --- @beautiful beautiful.notification_bg --- @tparam color notification_bg - ---- Notifications foreground color. --- @beautiful beautiful.notification_fg --- @tparam color notification_fg - ---- Notifications border width. --- @beautiful beautiful.notification_border_width --- @tparam int notification_border_width - ---- Notifications border color. --- @beautiful beautiful.notification_border_color --- @tparam color notification_border_color - ---- Notifications shape. --- @beautiful beautiful.notification_shape --- @tparam[opt] gears.shape notification_shape --- @see gears.shape - ---- Notifications opacity. --- @beautiful beautiful.notification_opacity --- @tparam[opt] int notification_opacity - ---- Notifications margin. --- @beautiful beautiful.notification_margin --- @tparam int notification_margin - ---- Notifications width. --- @beautiful beautiful.notification_width --- @tparam int notification_width - ---- Notifications height. --- @beautiful beautiful.notification_height --- @tparam int notification_height - ---- Notifications icon size. --- @beautiful beautiful.notification_icon_size --- @tparam int notification_icon_size - - --- Counter for the notifications --- Required for later access via DBUS -local counter = 1 - --- True if notifying is suspended local suspended = false +--TODO v5 Deprecate the public `naughty.notifications` (to make it private) + --- Index of notifications per screen and position. -- See config table for valid 'position' values. -- Each element is a table consisting of: @@ -229,13 +130,23 @@ capi.screen.connect_signal("removed", function(scr) naughty.notifications[scr] = nil end) ---- Notification state +--- Notification state. +-- +-- This function is deprecated, use `naughty.suspended`. +-- +-- @deprecated naughty.is_suspended function naughty.is_suspended() + gdebug.deprecate("Use naughty.suspended", {deprecated_in=5}) return suspended end ---- Suspend notifications +--- Suspend notifications. +-- +-- This function is deprecated, use `naughty.suspended = true`. +-- +-- @deprecated naughty.suspend function naughty.suspend() + gdebug.deprecate("Use naughty.suspended = true", {deprecated_in=5}) suspended = true end @@ -284,18 +195,32 @@ function naughty.disconnect_signal(name, func) return false end ---- Resume notifications -function naughty.resume() +local function resume() suspended = false for _, v in pairs(naughty.notifications.suspended) do - v.box.visible = true + v:emit_signal("request::display") if v.timer then v.timer:start() end end naughty.notifications.suspended = { } end ---- Toggle notification state +--- Resume notifications. +-- +-- This function is deprecated, use `naughty.suspended = false`. +-- +-- @deprecated naughty.resume +function naughty.resume() + gdebug.deprecate("Use naughty.suspended = false", {deprecated_in=5}) + resume() +end + +--- Toggle notification state. +-- +-- This function is deprecated, use `naughty.suspended = not naughty.suspended`. +-- +-- @deprecated naughty.toggle function naughty.toggle() + gdebug.deprecate("Use naughty.suspended = not naughty.suspended", {deprecated_in=5}) if suspended then naughty.resume() else @@ -303,124 +228,33 @@ function naughty.toggle() end end ---- Evaluate desired position of the notification by index - internal --- --- @param s 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(s, position, idx, width, height) - s = get_screen(s) - local ws = s.workarea - local v = {} - idx = idx or #naughty.notifications[s][position] + 1 - width = width or naughty.notifications[s][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.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[s][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 - - -- Find old notification to replace in case there is not enough room. - -- This tries to skip permanent notifications (without a timeout), - -- e.g. critical ones. - local find_old_to_replace = function() - for i = 1, idx-1 do - local n = naughty.notifications[s][position][i] - if n.timeout > 0 then - return n - end - end - -- Fallback to first one. - return naughty.notifications[s][position][1] - end - - -- if positioned outside workarea, destroy oldest popup and recalculate - if v.y + height > ws.y + ws.height or v.y < ws.y then - naughty.destroy(find_old_to_replace()) - idx = idx - 1 - v = get_offset(s, 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(s) - for p in pairs(naughty.notifications[s]) do - for i,notification in pairs(naughty.notifications[s][p]) do - local offset = get_offset(s, 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 -- +-- This function is deprecated in favor of +-- `notification:destroy(reason, keep_visible)`. +-- -- @param notification Notification object to be destroyed --- @param reason One of the reasons from notificationClosedReason +-- @param reason One of the reasons from `notification_closed_reason` -- @param[opt=false] keep_visible If true, keep the notification visible -- @return True if the popup was successfully destroyed, nil otherwise +-- @deprecated naughty.destroy function naughty.destroy(notification, reason, keep_visible) - 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 + gdebug.deprecate("Use notification:destroy(reason, keep_visible)", {deprecated_in=5}) - if not keep_visible then - notification.box.visible = false - arrange(scr) - end + if not notification then return end - if notification.destroy_cb and reason ~= naughty.notificationClosedReason.silent then - notification.destroy_cb(reason or naughty.notificationClosedReason.undefined) - end - return true - end + return notification:destroy(reason, keep_visible) end --- Destroy all notifications on given screens. -- -- @tparam table screens Table of screens on which notifications should be -- destroyed. If nil, destroy notifications on all screens. --- @tparam naughty.notificationClosedReason reason Reason for closing +-- @tparam naughty.notification_closed_reason reason Reason for closing -- notifications. -- @treturn true|nil True if all notifications were successfully destroyed, nil -- otherwise. +-- @see notification_closed_reason function naughty.destroy_all_notifications(screens, reason) if not screens then screens = {} @@ -432,7 +266,7 @@ function naughty.destroy_all_notifications(screens, reason) for _, scr in pairs(screens) do for _, list in pairs(naughty.notifications[scr]) do while #list > 0 do - ret = ret and naughty.destroy(list[1], reason) + ret = ret and list[1]:destroy(reason) end end end @@ -443,7 +277,17 @@ end -- -- @param id ID of the notification -- @return notification object if it was found, nil otherwise +-- @deprecated naughty.getById function naughty.getById(id) + gdebug.deprecate("Use naughty.get_by_id", {deprecated_in=5}) + return naughty.get_by_id(id) +end + +--- Get notification by ID +-- +-- @param id ID of the notification +-- @return notification object if it was found, nil otherwise +function naughty.get_by_id(id) -- iterate the notifications to get the notfications with the correct ID for s in pairs(naughty.notifications) do for p in pairs(naughty.notifications[s]) do @@ -456,145 +300,167 @@ function naughty.getById(id) 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. +-- +-- This function is deprecated, use `notification:reset_timeout(new_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. +-- @deprecated naughty.reset_timeout function naughty.reset_timeout(notification, new_timeout) - if notification.timer then notification.timer:stop() end + gdebug.deprecate("Use notification:reset_timeout(new_timeout)", {deprecated_in=5}) - local timeout = new_timeout or notification.timeout - set_timeout(notification, timeout) - notification.timeout = timeout + if not notification then return end - 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) - return textbox:set_markup_silently(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
. - if not setMarkup("", "\n") then - -- That failed, escape everything which might cause an error from pango - if not 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 - -local function update_size(notification) - - local n = notification - local s = n.size_info - local width = s.width - local height = s.height - local margin = s.margin - - -- calculate the width - if not width then - local w, _ = n.textbox:get_preferred_size(n.screen) - width = w + (n.iconbox and s.icon_w + 2 * margin or 0) + 2 * margin - end - - if width < s.actions_max_width then - width = s.actions_max_width - end - - -- calculate the height - if not height then - local w = width - (n.iconbox and s.icon_w + 2 * margin or 0) - 2 * margin - local h = n.textbox:get_height_for_width(w, n.screen) - if n.iconbox and s.icon_h + 2 * margin > h + 2 * margin then - height = s.icon_h + 2 * margin - else - height = h + 2 * margin - end - end - - height = height + s.actions_total_height - - -- crop to workarea size if too big - local workarea = n.screen.workarea - local border_width = s.border_width or 0 - local padding = naughty.config.padding or 0 - if width > workarea.width - 2*border_width - 2*padding then - width = workarea.width - 2*border_width - 2*padding - end - if height > workarea.height - 2*border_width - 2*padding then - height = workarea.height - 2*border_width - 2*padding - end - - -- set size in notification object - n.height = height + 2*border_width - n.width = width + 2*border_width - local offset = get_offset(n.screen, n.position, n.idx, n.width, n.height) - n.box:geometry({ - width = width, - height = height, - x = offset.x, - y = offset.y, - }) - n.idx = offset.idx - - -- update positions of other notifications - arrange(n.screen) + notification:reset_timeout(new_timeout) end --- Replace title and text of an existing notification. +-- +-- This function is deprecated, use `notification.text = new_text` and +-- `notification.title = new_title` +-- -- @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. +-- @deprecated naughty.replace_text function naughty.replace_text(notification, new_title, new_text) - local title = new_title + gdebug.deprecate( + "Use notification.text = new_text; notification.title = new_title", + {deprecated_in=5} + ) - if title then title = title .. "\n" else title = "" end + if not notification then return end - set_text(notification, title, new_text) - update_size(notification) + notification.title = new_title or notification.title + notification.text = new_text or notification.text +end + +-- Remove the notification from the internal list(s) +local function cleanup(self, reason) + if suspended then + for k, v in pairs(naughty.suspended) do + if v == self then + table.remove(naughty.suspended, k) + break + end + end + end + local scr = self.screen + + assert(naughty.notifications[scr][self.position][self.idx] == self) + table.remove(naughty.notifications[scr][self.position], self.idx) + + -- Update all indices + for k, n in ipairs(naughty.notifications[scr][self.position]) do + n.idx = k + end + + if self.timer then + self.timer:stop() + end + + if self.destroy_cb and reason ~= naughty.notification_closed_reason.silent then + self.destroy_cb(reason or naughty.notification_closed_reason.undefined) + end +end + +naughty.connect_signal("destroyed", cleanup) + +local function get_screen(s) + return s and capi.screen[s] +end + +-- Proxy the global suspension state on all notification objects +local function get_suspended(self) + return suspended and not self.ignore_suspend +end + +--- Emitted when a notification is created. +-- @signal added +-- @tparam naughty.notification notification The notification object + +--- Emitted when a notification is destroyed. +-- @signal destroyed +-- @tparam naughty.notification notification The notification object + +--- Emitted when a notification has to be displayed. +-- +-- To add an handler, use: +-- +-- naughty.connect_signal("request::display", function(notification, args) +-- -- do something +-- end) +-- +-- @tparam table notification The `naughty.notification` object. +-- @tparam table args Any arguments passed to the `naughty.notify` function, +-- including, but not limited to, all `naughty.notification` properties. +-- @signal request::display + +--- Emitted when a notification needs pre-display configuration. +-- +-- @tparam table notification The `naughty.notification` object. +-- @tparam table args Any arguments passed to the `naughty.notify` function, +-- including, but not limited to, all `naughty.notification` properties. +-- @signal request::preset + + + +-- Register a new notification object. +local function register(notification, args) + + -- Add the some more properties + rawset(notification, "get_suspended", get_suspended) + + --TODO v5 uncouple the notifications and the screen + local s = get_screen(args.screen or notification.preset.screen or screen.focused()) + + -- insert the notification to the table + table.insert(naughty.notifications[s][notification.position], notification) + notification.idx = #naughty.notifications[s][notification.position] + notification.screen = s + + if suspended and not args.ignore_suspend then + table.insert(naughty.notifications.suspended, notification) + end + + naughty.emit_signal("added", notification, args) + + assert(rawget(notification, "preset")) + + -- return the notification + return notification +end + +naughty.connect_signal("new", register) + +local function index_miss(_, key) + if key == "suspended" then + return suspended + else + return nil + end +end + +local function set_index_miss(_, key, value) + if key == "suspended" then + assert(type(value) == "boolean") + suspended = value + if value then + resume() + end + else + rawset(naughty, key, value) + end end --- Create a notification. -- +-- This function is deprecated, create notification objects instead: +-- +-- local notif = naughty.notification(args) +-- -- @tab args The argument table containing any of the arguments below. -- @string[opt=""] args.text Text of the notification. -- @string[opt] args.title Title of the notification. @@ -621,7 +487,7 @@ end -- @tparam[opt] func args.run Function to run on left click. The notification -- object will be passed to it as an argument. -- You need to call e.g. --- `notification.die(naughty.notificationClosedReason.dismissedByUser)` from +-- `notification.die(naughty.notification_closed_reason.dismissedByUser)` from -- there to dismiss the notification yourself. -- @tparam[opt] func args.destroy Function to run when notification is destroyed. -- @tparam[opt] table args.preset Table with any of the above parameters. @@ -638,250 +504,22 @@ end -- @usage naughty.notify({ title = "Achtung!", text = "You're idling", timeout = 0 }) -- @treturn ?table The notification object, or nil in case a notification was -- not displayed. +-- @deprecated naughty.notify + +local nnotif = nil + function naughty.notify(args) - if naughty.config.notify_callback then - args = naughty.config.notify_callback(args) - if not args then return end - end + gdebug.deprecate( + "Use local notif = naughty.notification(args)", + {deprecated_in=5} + ) - -- gather variables together - local preset = gtable.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 or - beautiful.notification_icon_size - local text = args.text or preset.text - local title = args.title or preset.title - local s = get_screen(args.screen or preset.screen or screen.focused()) - if not s then - local err = "naughty.notify: there is no screen available to display the following notification:" - err = string.format("%s title='%s' text='%s'", err, tostring(title or ""), tostring(text or "")) - require("gears.debug").print_warning(err) - return - end - local ontop = args.ontop or preset.ontop - local hover_timeout = args.hover_timeout or preset.hover_timeout - local position = args.position or preset.position - local actions = args.actions - local destroy_cb = args.destroy + --TODO v6 remove this hack + nnotif = nnotif or require("naughty.notification") - -- beautiful - local font = args.font or preset.font or beautiful.notification_font or - beautiful.font or capi.awesome.font - local fg = args.fg or preset.fg or - beautiful.notification_fg or beautiful.fg_normal or '#ffffff' - local bg = args.bg or preset.bg or - beautiful.notification_bg or beautiful.bg_normal or '#535d6c' - local border_color = args.border_color or preset.border_color or - beautiful.notification_border_color or beautiful.bg_focus or '#535d6c' - local border_width = args.border_width or preset.border_width or - beautiful.notification_border_width - local shape = args.shape or preset.shape or - beautiful.notification_shape - local width = args.width or preset.width or - beautiful.notification_width - local height = args.height or preset.height or - beautiful.notification_height - local margin = args.margin or preset.margin or - beautiful.notification_margin - local opacity = args.opacity or preset.opacity or - beautiful.notification_opacity - local notification = { screen = s, destroy_cb = destroy_cb, timeout = timeout } - - -- replace notification if needed - local reuse_box - if args.replaces_id then - local obj = naughty.getById(args.replaces_id) - if obj then - -- destroy this and ... - naughty.destroy(obj, naughty.notificationClosedReason.silent, true) - reuse_box = obj.box - 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.container.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.container.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:get_preferred_size(s) - local action_height = h + 2 * margin - local action_width = w + 2 * margin - - actionmarginbox:buttons(gtable.join( - button({ }, 1, callback), - button({ }, 3, callback) - )) - actionslayout:add(actionmarginbox) - - actions_total_height = actions_total_height + action_height - if actions_max_width < action_width then - actions_max_width = action_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) - -- urldecode URI path - icon = string.gsub(icon, "%%(%x%x)", function(x) return string.char(tonumber(x, 16)) end ) - end - -- try to guess icon if the provided one is non-existent/readable - if type(icon) == "string" and not gfs.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? - icon = surface.load_uncached(icon) - - -- if we have an icon, use it - if icon then - iconbox = wibox.widget.imagebox() - iconmargin = wibox.container.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 - notification.iconbox = iconbox - - -- create container wibox - notification.box = wibox({ fg = fg, - bg = bg, - border_color = border_color, - border_width = border_width, - shape_border_color = shape and border_color, - shape_border_width = shape and border_width, - shape = shape, - type = "notification" }) - - if reuse_box then - notification.box = reuse_box - end - - if hover_timeout then notification.box:connect_signal("mouse::enter", hover_destroy) end - - notification.size_info = { - width = width, - height = height, - icon_w = icon_w, - icon_h = icon_h, - margin = margin, - border_width = border_width, - actions_max_width = actions_max_width, - actions_total_height = actions_total_height, - } - - -- position the wibox - update_size(notification) - notification.box.ontop = ontop - notification.box.opacity = opacity - notification.box.visible = true - - -- 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(gtable.join(button({}, 1, nil, run), - button({}, 3, nil, function() - die(naughty.notificationClosedReason.dismissedByUser) - end))) - - -- insert the notification to the table - table.insert(naughty.notifications[s][notification.position], notification) - - if suspended and not args.ignore_suspend then - notification.box.visible = false - table.insert(naughty.notifications.suspended, notification) - end - - -- return the notification - return notification + return nnotif(args) end -return naughty +return setmetatable(naughty, {__index = index_miss, __newindex = set_index_miss}) -- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80 diff --git a/lib/naughty/dbus.lua b/lib/naughty/dbus.lua index bb9fcfc5..1d89a0d6 100644 --- a/lib/naughty/dbus.lua +++ b/lib/naughty/dbus.lua @@ -25,11 +25,14 @@ local tcat = table.concat local tins = table.insert local unpack = unpack or table.unpack -- luacheck: globals unpack (compatibility with Lua 5.1) local naughty = require("naughty.core") +local cst = require("naughty.constants") +local nnotif = require("naughty.notification") --- Notification library, dbus bindings local dbus = { config = {} } -- DBUS Notification constants +-- https://developer.gnome.org/notification-spec/#urgency-levels local urgency = { low = "\0", normal = "\1", @@ -46,9 +49,9 @@ local urgency = { -- @tfield table 3 critical urgency -- @table config.mapping dbus.config.mapping = { - {{urgency = urgency.low}, naughty.config.presets.low}, - {{urgency = urgency.normal}, naughty.config.presets.normal}, - {{urgency = urgency.critical}, naughty.config.presets.critical} + {{urgency = urgency.low}, cst.config.presets.low}, + {{urgency = urgency.normal}, cst.config.presets.normal}, + {{urgency = urgency.critical}, cst.config.presets.critical} } local function sendActionInvoked(notificationId, action) @@ -140,7 +143,7 @@ capi.dbus.connect_signal("org.freedesktop.Notifications", args.preset = gtable.join(args.preset, preset) end end - local preset = args.preset or naughty.config.defaults + local preset = args.preset or cst.config.defaults local notification if actions then args.actions = {} @@ -152,12 +155,12 @@ capi.dbus.connect_signal("org.freedesktop.Notifications", if action_id == "default" then args.run = function() sendActionInvoked(notification.id, "default") - naughty.destroy(notification, naughty.notificationClosedReason.dismissedByUser) + notification:destroy(cst.notification_closed_reason.dismissed_by_user) end elseif action_id ~= nil and action_text ~= nil then args.actions[action_text] = function() sendActionInvoked(notification.id, action_id) - naughty.destroy(notification, naughty.notificationClosedReason.dismissedByUser) + notification:destroy(cst.notification_closed_reason.dismissed_by_user) end end end @@ -190,14 +193,14 @@ capi.dbus.connect_signal("org.freedesktop.Notifications", args.timeout = expire / 1000 end args.freedesktop_hints = hints - notification = naughty.notify(args) + notification = nnotif(args) return "u", notification.id end return "u", "0" elseif data.member == "CloseNotification" then - local obj = naughty.getById(appname) + local obj = naughty.get_by_id(appname) if obj then - naughty.destroy(obj, naughty.notificationClosedReason.dismissedByCommand) + obj:destroy(cst.notification_closed_reason.dismissed_by_command) end elseif data.member == "GetServerInfo" or data.member == "GetServerInformation" then -- name of notification app, name of vender, version, specification version diff --git a/lib/naughty/init.lua b/lib/naughty/init.lua index 71dee9fc..7b732554 100644 --- a/lib/naughty/init.lua +++ b/lib/naughty/init.lua @@ -9,6 +9,9 @@ if dbus then naughty.dbus = require("naughty.dbus") end +naughty.layout = require("naughty.layout") +naughty.notification = require("naughty.notification") + return naughty -- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80 diff --git a/lib/naughty/layout/init.lua b/lib/naughty/layout/init.lua new file mode 100644 index 00000000..c6df968a --- /dev/null +++ b/lib/naughty/layout/init.lua @@ -0,0 +1,9 @@ +--------------------------------------------------------------------------- +-- @author Emmanuel Lepage Vallee <elv1313@gmail.com> +-- @copyright 2017 Emmanuel Lepage Vallee +-- @module naughty.layout +--------------------------------------------------------------------------- + +return { + legacy = require("naughty.layout.legacy") +} diff --git a/lib/naughty/layout/legacy.lua b/lib/naughty/layout/legacy.lua new file mode 100644 index 00000000..a757ff3a --- /dev/null +++ b/lib/naughty/layout/legacy.lua @@ -0,0 +1,544 @@ +---------------------------------------------------------------------------- +--- A notification popup widget. +-- +-- This is the legacy notification widget. It was the default until Awesome +-- v4.3 but is now being deprecated in favor of a more flexible widget. +-- +-- The reason for this is/was that this widget is inflexible and mutate the +-- state of the notification object in a way that hinder other notification +-- widgets. +-- +-- If no other notification widget is specified, Awesome fallback to this +-- widget. +-- +--@DOC_naughty_actions_EXAMPLE@ +-- +-- @author koniu <gkusnierz@gmail.com> +-- @author Emmanuel Lepage Vallee <elv1313@gmail.com> +-- @copyright 2008 koniu +-- @copyright 2017 Emmanuel Lepage Vallee +-- @classmod naughty.layout.legacy +---------------------------------------------------------------------------- + +local capi = { screen = screen, awesome = awesome } +local naughty = require("naughty.core") +local screen = require("awful.screen") +local button = require("awful.button") +local beautiful = require("beautiful") +local surface = require("gears.surface") +local gtable = require("gears.table") +local wibox = require("wibox") +local gfs = require("gears.filesystem") +local timer = require("gears.timer") +local gmath = require("gears.math") +local cairo = require("lgi").cairo +local util = require("awful.util") + +local function get_screen(s) + return s and capi.screen[s] +end + +-- This is a copy of the table found in `naughty.core`. The reason the copy +-- exists is to make sure there is only unidirectional coupling between the +-- legacy widget (this class) and `naughty.core`. Exposing the "raw" +-- notification list is also a bad design and might cause indices and position +-- corruption. While it cannot be removed from the public API (yet), it can at +-- least be blacklisted internally. +local current_notifications = setmetatable({}, {__mode = "k"}) + +screen.connect_for_each_screen(function(s) + current_notifications[s] = { + top_left = {}, + top_middle = {}, + top_right = {}, + bottom_left = {}, + bottom_middle = {}, + bottom_right = {}, + } +end) + +-- Counter for the notifications +-- Required for later access via DBUS +local counter = 1 + +--- Evaluate desired position of the notification by index - internal +-- +-- @param s 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(s, position, idx, width, height) + s = get_screen(s) + local ws = s.workarea + local v = {} + idx = idx or #current_notifications[s][position] + 1 + width = width or current_notifications[s][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.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 + local n = current_notifications[s][position][i] + + -- `n` will not nil when there is too many notifications to fit in `s` + if n then + existing = existing + n.height + naughty.config.spacing + end + 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 + + -- Find old notification to replace in case there is not enough room. + -- This tries to skip permanent notifications (without a timeout), + -- e.g. critical ones. + local find_old_to_replace = function() + for i = 1, idx-1 do + local n = current_notifications[s][position][i] + if n.timeout > 0 then + return n + end + end + -- Fallback to first one. + return current_notifications[s][position][1] + end + + -- if positioned outside workarea, destroy oldest popup and recalculate + if v.y + height > ws.y + ws.height or v.y < ws.y then + local n = find_old_to_replace() + if n then + n:destroy(naughty.notification_closed_reason.too_many_on_screen) + end + v = get_offset(s, position, idx, width, height) + end + + return v +end + +local escape_pattern = "[<>&]" +local escape_subs = { ['<'] = "<", ['>'] = ">", ['&'] = "&" } + +-- Cache the markup +local function set_escaped_text(self) + local text, title = self.text or "", self.title or "" + + if title then title = title .. "\n" else title = "" end + + local textbox = self.textbox + + local function set_markup(pattern, replacements) + return textbox:set_markup_silently(string.format('%s%s', title, text:gsub(pattern, replacements))) + end + + local function set_text() + 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
. + if not set_markup("", "\n") then + -- That failed, escape everything which might cause an error from pango + if not set_markup(escape_pattern, escape_subs) then + -- Ok, just ignore all pango markup. If this fails, we got some invalid utf8 + if not pcall(set_text) then + textbox:set_markup("<Invalid markup or UTF8, cannot display message>") + end + end + end +end + +naughty.connect_signal("property::text" ,set_escaped_text) +naughty.connect_signal("property::title",set_escaped_text) + + +--- Re-arrange notifications according to their position and index - internal +-- +-- @return None +local function arrange(s) + -- {} in case the screen has been deleted + for p in pairs(current_notifications[s] or {}) do + for i,notification in pairs(current_notifications[s][p]) do + local offset = get_offset(s, p, i, notification.width, notification.height) + notification.box:geometry({ x = offset.x, y = offset.y }) + end + end +end + +local function update_size(notification) + local n = notification + local s = n.size_info + local width = s.width + local height = s.height + local margin = s.margin + + -- calculate the width + if not width then + local w, _ = n.textbox:get_preferred_size(n.screen) + width = w + (n.iconbox and s.icon_w + 2 * margin or 0) + 2 * margin + end + + if width < s.actions_max_width then + width = s.actions_max_width + end + + if s.max_width then + width = math.min(width, s.max_width) + end + + + -- calculate the height + if not height then + local w = width - (n.iconbox and s.icon_w + 2 * margin or 0) - 2 * margin + local h = n.textbox:get_height_for_width(w, n.screen) + if n.iconbox and s.icon_h + 2 * margin > h + 2 * margin then + height = s.icon_h + 2 * margin + else + height = h + 2 * margin + end + end + + height = height + s.actions_total_height + + if s.max_height then + height = math.min(height, s.max_height) + end + + -- crop to workarea size if too big + local workarea = n.screen.workarea + local border_width = s.border_width or 0 + local padding = naughty.config.padding or 0 + if width > workarea.width - 2*border_width - 2*padding then + width = workarea.width - 2*border_width - 2*padding + end + if height > workarea.height - 2*border_width - 2*padding then + height = workarea.height - 2*border_width - 2*padding + end + + -- set size in notification object + n.height = height + 2*border_width + n.width = width + 2*border_width + local offset = get_offset(n.screen, n.position, n.idx, n.width, n.height) + n.box:geometry({ + width = width, + height = height, + x = offset.x, + y = offset.y, + }) + + -- update positions of other notifications + arrange(n.screen) +end + + +local function cleanup(self, _ --[[reason]], keep_visible) + -- It is not a legacy notification + if not self.box then return end + + local scr = self.screen + + assert(current_notifications[scr][self.position][self.idx] == self) + table.remove(current_notifications[scr][self.position], self.idx) + + if (not keep_visible) or (not scr) then + self.box.visible = false + end + + arrange(scr) +end + +naughty.connect_signal("destroyed", cleanup) + +--- The default notification GUI handler. +-- +-- To disable this handler, use: +-- +-- naughty.disconnect_signal( +-- "request::display", naughty.default_notification_handler +-- ) +-- +-- It looks like: +-- +--@DOC_naughty_actions_EXAMPLE@ +-- +-- @tparam table notification The `naughty.notification` object. +-- @tparam table args Any arguments passed to the `naughty.notify` function, +-- including, but not limited to all `naughty.notification` properties. +-- @signalhandler naughty.default_notification_handler +function naughty.default_notification_handler(notification, args) + + -- If request::display is called more than once, simply make sure the wibox + -- is visible. + if notification.box then + notification.box.visible = true + return + end + + local preset = notification.preset + local text = args.text or preset.text + local title = args.title or preset.title + local s = get_screen(args.screen or preset.screen or screen.focused()) + + if not s then + local err = "naughty.notify: there is no screen available to display the following notification:" + err = string.format("%s title='%s' text='%s'", err, tostring(title or ""), tostring(text or "")) + require("gears.debug").print_warning(err) + return + end + + local timeout = args.timeout or preset.timeout + local icon = args.icon or preset.icon + local icon_size = args.icon_size or preset.icon_size + or beautiful.notification_icon_size + local ontop = args.ontop or preset.ontop + local hover_timeout = args.hover_timeout or preset.hover_timeout + local position = args.position or preset.position + local actions = args.actions + local destroy_cb = args.destroy + + notification.screen = s + notification.destroy_cb = destroy_cb + notification.timeout = timeout + + -- beautiful + local font = args.font or preset.font or beautiful.notification_font or + beautiful.font or capi.awesome.font + local fg = args.fg or preset.fg or + beautiful.notification_fg or beautiful.fg_normal or '#ffffff' + local bg = args.bg or preset.bg or + beautiful.notification_bg or beautiful.bg_normal or '#535d6c' + local border_color = args.border_color or preset.border_color or + beautiful.notification_border_color or beautiful.bg_focus or '#535d6c' + local border_width = args.border_width or preset.border_width or + beautiful.notification_border_width + local shape = args.shape or preset.shape or + beautiful.notification_shape + local width = args.width or preset.width or + beautiful.notification_width + local height = args.height or preset.height or + beautiful.notification_height + local max_width = args.max_width or preset.max_width or + beautiful.notification_max_width + local max_height = args.max_height or preset.max_height or + beautiful.notification_max_height + local margin = args.margin or preset.margin or + beautiful.notification_margin + local opacity = args.opacity or preset.opacity or + beautiful.notification_opacity + + -- replace notification if needed + local reuse_box + if args.replaces_id then + local obj = naughty.get_by_id(args.replaces_id) + if obj then + -- destroy this and ... + naughty.destroy(obj, naughty.notification_closed_reason.silent, true) + reuse_box = obj.box + 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 + + -- hook destroy + notification.timeout = timeout + local die = notification.die + + local run = function () + if args.run then + args.run(notification) + else + die(naughty.notification_closed_reason.dismissed_by_user) + end + end + + local hover_destroy = function () + if hover_timeout == 0 then + die(naughty.notification_closed_reason.expired) + else + if notification.timer then notification.timer:stop() end + notification.timer = timer { timeout = hover_timeout } + notification.timer:connect_signal("timeout", function() die(naughty.notification_closed_reason.expired) end) + notification.timer:start() + end + end + + -- create textbox + local textbox = wibox.widget.textbox() + local marginbox = wibox.container.margin() + marginbox:set_margins(margin) + marginbox:set_widget(textbox) + textbox:set_valign("middle") + textbox:set_font(font) + + notification.textbox = textbox + set_escaped_text(notification) + + 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.container.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:get_preferred_size(s) + local action_height = h + 2 * margin + local action_width = w + 2 * margin + + actionmarginbox:buttons(gtable.join( + button({ }, 1, callback), + button({ }, 3, callback) + )) + actionslayout:add(actionmarginbox) + + actions_total_height = actions_total_height + action_height + if actions_max_width < action_width then + actions_max_width = action_width + end + end + end + + local size_info = { + width = width, + height = height, + max_width = max_width, + max_height = max_height, + margin = margin, + border_width = border_width, + actions_max_width = actions_max_width, + actions_total_height = actions_total_height, + } + + -- create iconbox + local iconbox = nil + local iconmargin = nil + 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) + -- urldecode URI path + icon = string.gsub(icon, "%%(%x%x)", function(x) return string.char(tonumber(x, 16)) end ) + end + -- try to guess icon if the provided one is non-existent/readable + if type(icon) == "string" and not gfs.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? + icon = surface.load_uncached(icon) + + -- if we have an icon, use it + if icon then + iconbox = wibox.widget.imagebox() + iconmargin = wibox.container.margin(iconbox, margin, margin, margin, margin) + + if icon_size and (icon:get_height() > icon_size or icon:get_width() > icon_size) then + size_info.icon_scale_factor = icon_size / math.max(icon:get_height(), + icon:get_width()) + + size_info.icon_w = icon:get_width () * size_info.icon_scale_factor + size_info.icon_h = icon:get_height() * size_info.icon_scale_factor + + local scaled = + cairo.ImageSurface(cairo.Format.ARGB32, + gmath.round(size_info.icon_w), + gmath.round(size_info.icon_h)) + + local cr = cairo.Context(scaled) + cr:scale(size_info.icon_scale_factor, size_info.icon_scale_factor) + cr:set_source_surface(icon, 0, 0) + cr:paint() + icon = scaled + else + size_info.icon_w = icon:get_width () + size_info.icon_h = icon:get_height() + end + iconbox:set_resize(false) + iconbox:set_image(icon) + end + end + notification.iconbox = iconbox + + -- create container wibox + notification.box = wibox({ fg = fg, + bg = bg, + border_color = border_color, + border_width = border_width, + shape_border_color = shape and border_color, + shape_border_width = shape and border_width, + shape = shape, + type = "notification" }) + + if reuse_box then + notification.box = reuse_box + end + + if hover_timeout then notification.box:connect_signal("mouse::enter", hover_destroy) end + + notification.size_info = size_info + + -- position the wibox + update_size(notification) + notification.box.ontop = ontop + notification.box.opacity = opacity + notification.box.visible = true + + -- 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(gtable.join(button({}, 1, nil, run), + button({}, 3, nil, function() + die(naughty.notification_closed_reason.dismissed_by_user) + end))) + + -- insert the notification to the table + table.insert(current_notifications[s][notification.position], notification) + + if naughty.suspended and not args.ignore_suspend then + notification.box.visible = false + end +end + +naughty.connect_signal("request::display", naughty.default_notification_handler) diff --git a/lib/naughty/notification.lua b/lib/naughty/notification.lua new file mode 100644 index 00000000..138a9915 --- /dev/null +++ b/lib/naughty/notification.lua @@ -0,0 +1,416 @@ +--------------------------------------------------------------------------- +--- A notification object. +-- +-- This class creates individual notification objects that can be manipulated +-- to extend the default behavior. +-- +-- This class doesn't define the actual widget, but is rather intended as a data +-- object to hold the properties. All examples assume the default widgets, but +-- the whole implementation can be replaced. +-- +--@DOC_naughty_actions_EXAMPLE@ +-- +-- @author Emmanuel Lepage Vallee +-- @copyright 2008 koniu +-- @copyright 2017 Emmanuel Lepage Vallee +-- @classmod naughty.notification +--------------------------------------------------------------------------- +local gobject = require("gears.object") +local gtable = require("gears.table") +local timer = require("gears.timer") +local cst = require("naughty.constants") +local naughty = require("naughty.core") + +local notification = {} + +--- Notifications font. +-- @beautiful beautiful.notification_font +-- @tparam string|lgi.Pango.FontDescription notification_font + +--- Notifications background color. +-- @beautiful beautiful.notification_bg +-- @tparam color notification_bg + +--- Notifications foreground color. +-- @beautiful beautiful.notification_fg +-- @tparam color notification_fg + +--- Notifications border width. +-- @beautiful beautiful.notification_border_width +-- @tparam int notification_border_width + +--- Notifications border color. +-- @beautiful beautiful.notification_border_color +-- @tparam color notification_border_color + +--- Notifications shape. +-- @beautiful beautiful.notification_shape +-- @tparam[opt] gears.shape notification_shape +-- @see gears.shape + +--- Notifications opacity. +-- @beautiful beautiful.notification_opacity +-- @tparam[opt] int notification_opacity + +--- Notifications margin. +-- @beautiful beautiful.notification_margin +-- @tparam int notification_margin + +--- Notifications width. +-- @beautiful beautiful.notification_width +-- @tparam int notification_width + +--- Notifications height. +-- @beautiful beautiful.notification_height +-- @tparam int notification_height + +--- Unique identifier of the notification. +-- This is the equivalent to a PID as allows external applications to select +-- notifications. +-- @property text +-- @param string +-- @see title + +--- Text of the notification. +-- @property text +-- @param string +-- @see title + +--- Title of the notification. +--@DOC_naughty_helloworld_EXAMPLE@ +-- @property title +-- @param string + +--- Time in seconds after which popup expires. +-- Set 0 for no timeout. +-- @property timeout +-- @param number + +--- Delay in seconds after which hovered popup disappears. +-- @property hover_timeout +-- @param number + +--- Target screen for the notification. +-- @property screen +-- @param screen + +--- Corner of the workarea displaying the popups. +-- +-- The possible values are: +-- +-- * *top_right* +-- * *top_left* +-- * *bottom_left* +-- * *bottom_right* +-- * *top_middle* +-- * *bottom_middle* +-- +--@DOC_awful_notification_corner_EXAMPLE@ +-- +-- @property position +-- @param string + +--- Boolean forcing popups to display on top. +-- @property ontop +-- @param boolean + +--- Popup height. +-- @property height +-- @param number + +--- Popup width. +-- @property width +-- @param number + +--- Notification font. +--@DOC_naughty_colors_EXAMPLE@ +-- @property font +-- @param string + +--- Path to icon. +-- @property icon +-- @tparam string|surface icon + +--- Desired icon size in px. +-- @property icon_size +-- @param number + +--- Foreground color. +-- @property fg +-- @tparam string|color|pattern fg +-- @see title +-- @see gears.color + +--- Background color. +-- @property bg +-- @tparam string|color|pattern bg +-- @see title +-- @see gears.color + +--- Border width. +-- @property border_width +-- @param number +-- @see title + +--- Border color. +-- @property border_color +-- @param string +-- @see title +-- @see gears.color + +--- Widget shape. +--@DOC_naughty_shape_EXAMPLE@ +-- @property shape + +--- Widget opacity. +-- @property opacity +-- @param number From 0 to 1 + +--- Widget margin. +-- @property margin +-- @tparam number|table margin +-- @see shape + +--- Function to run on left click. +-- @property run +-- @param function + +--- Function to run when notification is destroyed. +-- @property destroy +-- @param function + +--- Table with any of the above parameters. +-- args will override ones defined +-- in the preset. +-- @property preset +-- @param table + +--- Replace the notification with the given ID. +-- @property replaces_id +-- @param number + +--- 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. +-- @property callback +-- @param function + +--- A table containing strings that represents actions to buttons. +-- +-- The table key (a number) is used by DBus to set map the action. +-- +-- @property actions +-- @param table + +--- Ignore this notification, do not display. +-- +-- Note that this property has to be set in a `preset` or in a `request::preset` +-- handler. +-- +-- @property ignore +-- @param boolean + +--- Tell if the notification is currently suspended (read only). +-- +-- This is always equal to `naughty.suspended` +--@property suspended +--@param boolean + +--- Emitted when the notification is destroyed. +-- @signal destroyed + +-- . --FIXME needs a description +-- @property ignore_suspend If set to true this notification +-- will be shown even if notifications are suspended via `naughty.suspend`. + +--FIXME remove the screen attribute, let the handlers decide +-- document all handler extra properties + +--FIXME add methods such as persist + +--- Destroy notification by notification object +-- +-- @tparam string reason One of the reasons from `notification_closed_reason` +-- @tparam[opt=false] boolean keep_visible If true, keep the notification visible +-- @return True if the popup was successfully destroyed, nil otherwise +function notification:destroy(reason, keep_visible) + self:emit_signal("destroyed") + + return true +end + +--- Set new notification timeout. +-- @tparam number new_timeout Time in seconds after which notification disappears. +function notification:reset_timeout(new_timeout) + if self.timer then self.timer:stop() end + + local timeout = new_timeout or self.timeout + self:set_timeout(self, timeout) + self.timeout = timeout + + self.timer:start() +end + +function notification:set_id(new_id) + assert(self._private.id == nil, "Notification identifier can only be set once") + self._private.id = new_id + self:emit_signal("property::id", new_id) +end + +function notification:set_timeout(timeout) + local die = function (reason) + self:destroy(reason) + end + + if self.timer and self._private.timeout == timeout then return end + + if timeout > 0 then + local timer_die = timer { timeout = timeout } + timer_die:connect_signal("timeout", function() die(cst.notification_closed_reason.expired) end) + if not self.suspended then --FIXME there's still a dependency loop to fix before it works + timer_die:start() + end + + -- Prevent a memory leak and the accumulation of active timers + if self.timer and self.timer.started then + self.timer:stop() + end + + self.timer = timer_die + end + self.die = die + self._private.timeout = timeout +end + +local properties = { + "text" , "title" , "timeout" , "hover_timeout" , + "screen" , "position", "ontop" , "border_width" , + "width" , "font" , "icon" , "icon_size" , + "fg" , "bg" , "height" , "border_color" , + "shape" , "opacity" , "margin" , "ignore_suspend", + "destroy" , "preset" , "callback", "replaces_id" , + "actions" , "run" , "id" , "ignore" , +} + +for _, prop in ipairs(properties) do + notification["get_"..prop] = notification["get_"..prop] or function(self) + -- It's possible this could be called from the `request::preset` handler. + -- `rawget()` is necessary to avoid a stack overflow. + local preset = rawget(self, "preset") + + return self._private[prop] + or (preset and preset[prop]) + or cst.config.defaults[prop] + end + + notification["set_"..prop] = notification["set_"..prop] or function(self, value) + self._private[prop] = value + self:emit_signal("property::"..prop, value) + return + end + +end + +--- Create a notification. +-- +-- @tab args The argument table containing any of the arguments below. +-- @string[opt=""] args.text Text of the notification. +-- @string[opt] args.title Title of the notification. +-- @int[opt=5] args.timeout Time in seconds after which popup expires. +-- Set 0 for no timeout. +-- @int[opt] args.hover_timeout Delay in seconds after which hovered popup disappears. +-- @tparam[opt=focused] integer|screen args.screen Target screen for the notification. +-- @string[opt="top_right"] args.position Corner of the workarea displaying the popups. +-- Values: `"top_right"`, `"top_left"`, `"bottom_left"`, +-- `"bottom_right"`, `"top_middle"`, `"bottom_middle"`. +-- @bool[opt=true] args.ontop Boolean forcing popups to display on top. +-- @int[opt=`beautiful.notification_height` or auto] args.height Popup height. +-- @int[opt=`beautiful.notification_width` or auto] args.width Popup width. +-- @string[opt=`beautiful.notification_font` or `beautiful.font` or `awesome.font`] args.font Notification font. +-- @string[opt] args.icon Path to icon. +-- @int[opt] args.icon_size Desired icon size in px. +-- @string[opt=`beautiful.notification_fg` or `beautiful.fg_focus` or `'#ffffff'`] args.fg Foreground color. +-- @string[opt=`beautiful.notification_fg` or `beautiful.bg_focus` or `'#535d6c'`] args.bg Background color. +-- @int[opt=`beautiful.notification_border_width` or 1] args.border_width Border width. +-- @string[opt=`beautiful.notification_border_color` or +-- `beautiful.border_focus` or `'#535d6c'`] args.border_color Border color. +-- @tparam[opt=`beautiful.notification_shape`] gears.shape args.shape Widget shape. +-- @tparam[opt=`beautiful.notification_opacity`] gears.opacity args.opacity Widget opacity. +-- @tparam[opt=`beautiful.notification_margin`] gears.margin args.margin Widget margin. +-- @tparam[opt] func args.run Function to run on left click. The notification +-- object will be passed to it as an argument. +-- You need to call e.g. +-- `notification.die(naughty.notification_closed_reason.dismissedByUser)` from +-- there to dismiss the notification yourself. +-- @tparam[opt] func args.destroy Function to run when notification is destroyed. +-- @tparam[opt] 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[opt] int args.replaces_id Replace the notification with the given ID. +-- @tparam[opt] 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[opt] table args.actions Mapping that maps a string to a callback when this +-- action is selected. +-- @bool[opt=false] args.ignore_suspend If set to true this notification +-- will be shown even if notifications are suspended via `naughty.suspend`. +-- @usage naughty.notify({ title = "Achtung!", text = "You're idling", timeout = 0 }) +-- @treturn ?table The notification object, or nil in case a notification was +-- not displayed. +-- @function naughty.notification +local function create(args) + if cst.config.notify_callback then + args = cst.config.notify_callback(args) + if not args then return end + end + + local n = gobject { + enable_properties = true, + } + + assert(naughty.emit_signal) + -- Make sure all signals bubble up + n:_connect_everything(naughty.emit_signal) + + -- Avoid modifying the original table + local private = {} + + -- gather variables together + rawset(n, "preset", gtable.join( + cst.config.defaults or {}, + args.preset or cst.config.presets.normal or {}, + rawget(n, "preset") or {} + )) + + for k, v in pairs(n.preset) do + private[k] = v + end + + for k, v in pairs(args) do + private[k] = v + end + + rawset(n, "_private", private) + + gtable.crush(n, notification, true) + + -- Allow extensions to create override the preset with custom data + naughty.emit_signal("request::preset", n, args) + + -- Register the notification before requesting a widget + n:emit_signal("new", args) + + -- Let all listeners handle the actual visual aspects + if (not n.ignore) and (not n.preset.ignore) then + naughty.emit_signal("request::display", n, args) + end + + -- Because otherwise the setter logic would not be executed + if n._private.timeout then + n:set_timeout(n._private.timeout or n.preset.timeout) + end + + return n +end + +return setmetatable(notification, {__call = function(_, ...) return create(...) end}) diff --git a/tests/examples/text/awful/keygrabber/autostart.lua b/tests/examples/text/awful/keygrabber/autostart.lua index ac4e5d58..d8814c66 100644 --- a/tests/examples/text/awful/keygrabber/autostart.lua +++ b/tests/examples/text/awful/keygrabber/autostart.lua @@ -1,7 +1,7 @@ local awful = { keygrabber = require("awful.keygrabber") } --DOC_HIDE -local naughty = { notify = function() end } --DOC_HIDE +local naughty = { notification = function() end } --DOC_HIDE local autostart_works = false --DOC_HIDE @@ -11,7 +11,7 @@ awful.keygrabber { stop_callback = function(_, _, _, sequence) autostart_works = true --DOC_HIDE assert(sequence == "abc") --DOC_HIDE - naughty.notify{text="The keys were:"..sequence} + naughty.notification {text="The keys were:"..sequence} end, } diff --git a/tests/examples/wibox/awidget/prompt/keypress.lua b/tests/examples/wibox/awidget/prompt/keypress.lua index 50fc5634..23254042 100644 --- a/tests/examples/wibox/awidget/prompt/keypress.lua +++ b/tests/examples/wibox/awidget/prompt/keypress.lua @@ -15,7 +15,7 @@ local naughty = {} --DOC_HIDE prompt = "Run: ", keypressed_callback = function(mod, key, cmd) --luacheck: no unused args if key == "Shift_L" then - notif = naughty.notify { text = "Shift pressed" } + notif = naughty.notification { text = "Shift pressed" } end end, keyreleased_callback = function(mod, key, cmd) --luacheck: no unused args diff --git a/tests/examples/wibox/awidget/prompt/simple.lua b/tests/examples/wibox/awidget/prompt/simple.lua index bdf98a36..8e64d1fc 100644 --- a/tests/examples/wibox/awidget/prompt/simple.lua +++ b/tests/examples/wibox/awidget/prompt/simple.lua @@ -18,7 +18,7 @@ local naughty = {} --DOC_HIDE textbox = atextbox, exe_callback = function(input) if not input or #input == 0 then return end - naughty.notify{ text = "The input was: "..input } + naughty.notification { text = "The input was: "..input } end } end diff --git a/tests/test-screen-changes.lua b/tests/test-screen-changes.lua index 12146c44..c12bc966 100644 --- a/tests/test-screen-changes.lua +++ b/tests/test-screen-changes.lua @@ -31,7 +31,7 @@ local steps = { fake_screen.selected_tag.layout = max -- Display a notification on the screen-to-be-removed - naughty.notify{ text = "test", screen = fake_screen } + naughty.notification { text = "test", screen = fake_screen } return true end