notification.py (14639B)
1 """ 2 Qtile plugin that acts as a notification server and draws notification windows. 3 4 Example usage: 5 6 import qtools.notification 7 notifier = qtools.notification.Server() 8 keys.extend([EzKey(k, v) for k, v in { 9 'M-<grave>': notifier.lazy_prev, 10 'M-S-<grave>': notifier.lazy_next, 11 'C-<space>': notifier.lazy_close, 12 }.items()]) 13 14 """ 15 16 17 from libqtile import configurable, hook, images, pangocffi, qtile 18 from libqtile.lazy import lazy 19 from libqtile.log_utils import logger 20 from libqtile.notify import notifier 21 from libqtile.popup import Popup 22 23 24 class Server(configurable.Configurable): 25 """ 26 This class provides a full graphical notification manager for the 27 org.freedesktop.Notifications service implemented in libqtile.notify. 28 29 Hints can be provided by notification clients to modify behaviour: 30 hint behaviour 31 32 The format option determines what text is shown on the popup windows, and supports 33 markup and new line characters e.g. '<b>{summary}</b>\n{body}'. Available 34 placeholders are summary, body and app_name. 35 36 Foreground and background colours can be specified either as tuples/lists of 3 37 strings, corresponding to low, normal and critical urgencies, or just a single 38 string which will then be used for all urgencies. The timeout and border options can 39 be set in the same way. 40 41 The max_windows option limits how many popup windows can be drawn at a time. When 42 more notifications are recieved while the maximum number are already drawn, 43 notifications are queued and displayed when existing notifications are closed. 44 45 TODO: 46 - overflow 47 - select screen / follow mouse/keyboard focus 48 - critical notifications to replace any visible non-critical notifs immediately? 49 - hints: image-path, desktop-entry (for icon) 50 - hints: Server parameters set for single notification? 51 - hints: progress value e.g. int:value:42 with drawing 52 53 """ 54 defaults = [ 55 ('x', 32, 'x position on screen to start drawing notifications.'), 56 ('y', 64, 'y position on screen to start drawing notifications.'), 57 ('width', 192, 'Width of notifications.'), 58 ('height', 64, 'Height of notifications.'), 59 ('format', '{summary}\n{body}', 'Text format.'), 60 ( 61 'foreground', 62 ('#ffffff', '#ffffff', '#ffffff'), 63 'Foreground colour of notifications, in ascending order of urgency.', 64 ), 65 ( 66 'background', 67 ('#111111', '#111111', '#111111'), 68 'Background colour of notifications, in ascending order of urgency.', 69 ), 70 ( 71 'border', 72 ('#111111', '#111111', '#111111'), 73 'Border colours in ascending order of urgency. Or None for none.', 74 ), 75 ( 76 'timeout', 77 (5000, 5000, 0), 78 'Millisecond timeout duration, in ascending order of urgency.', 79 ), 80 ('opacity', 1.0, 'Opacity of notifications.'), 81 ('border_width', 4, 'Line width of drawn borders.'), 82 ('corner_radius', None, 'Corner radius for round corners, or None.'), 83 ('font', 'sans', 'Font used in notifications.'), 84 ('font_size', 14, 'Size of font.'), 85 ('fontshadow', None, 'Color for text shadows, or None for no shadows.'), 86 ('text_alignment', 'left', 'Text alignment: left, center or right.'), 87 ('horizontal_padding', None, 'Padding at sides of text.'), 88 ('vertical_padding', None, 'Padding at top and bottom of text.'), 89 ('line_spacing', 4, 'Space between lines.'), 90 ( 91 'overflow', 92 'truncate', 93 'How to deal with too much text: more_width, more_height, or truncate.', 94 ), 95 ('max_windows', 2, 'Maximum number of windows to show at once.'), 96 ('gap', 12, 'Vertical gap between popup windows.'), 97 ('sticky_history', True, 'Disable timeout when browsing history.'), 98 ('icon_size', 36, 'Pixel size of any icons.'), 99 ('fullscreen', 'show', 'What to do when in fullscreen: show, hide, or queue.'), 100 ('screen', 'focus', 'How to select a screen: focus, mouse, or an int.'), 101 ] 102 capabilities = {'body', 'body-markup', 'actions'} 103 # specification: https://developer.gnome.org/notification-spec/ 104 105 def __init__(self, **config): 106 configurable.Configurable.__init__(self, **config) 107 self.add_defaults(Server.defaults) 108 self._hidden = [] 109 self._shown = [] 110 self._queue = [] 111 self._positions = [] 112 self._scroll_popup = None 113 self._current_id = 0 114 self._notif_id = None 115 self._paused = False 116 self._icons = {} 117 118 self._make_attr_list('foreground') 119 self._make_attr_list('background') 120 self._make_attr_list('timeout') 121 self._make_attr_list('border') 122 123 def __getattr__(self, name): 124 """ 125 Using this, we can get e.g. Server.lazy_close which is the equivalent of 126 lazy.function(Server.close) but more convenient for setting keybindings. 127 """ 128 if name.startswith('lazy_'): 129 return lazy.function(getattr(self, name[5:])) 130 return configurable.Configurable.__getattr__(self, name) 131 132 def _make_attr_list(self, attr): 133 """ 134 Turns '#000000' into ('#000000', '#000000', '#000000') 135 """ 136 value = getattr(self, attr) 137 if not isinstance(value, (tuple, list)): 138 setattr(self, attr, (value,) * 3) 139 140 def configure(self): 141 """ 142 This method needs to be called to set up the Server with the Qtile manager and 143 create the required popup windows. 144 """ 145 if self.horizontal_padding is None: 146 self.horizontal_padding = self.font_size / 2 147 if self.vertical_padding is None: 148 self.vertical_padding = self.font_size / 2 149 150 popup_config = {} 151 for opt in Popup.defaults: 152 key = opt[0] 153 if hasattr(self, key): 154 value = getattr(self, key) 155 if isinstance(value, (tuple, list)): 156 popup_config[key] = value[1] 157 else: 158 popup_config[key] = value 159 160 for win in range(self.max_windows): 161 popup = Popup(qtile, **popup_config) 162 popup.win.handle_ButtonPress = self._buttonpress(popup) 163 popup.replaces_id = None 164 self._hidden.append(popup) 165 self._positions.append( 166 (self.x, self.y + win * (self.height + 2 * self.border_width + 167 self.gap)) 168 ) 169 170 notifier.register(self._notify, Server.capabilities) 171 172 def _buttonpress(self, popup): 173 def _(event): 174 if event.detail == 1: 175 self._close(popup) 176 return _ 177 178 def _notify(self, notif): 179 """ 180 This method is registered with the NotificationManager to handle notifications 181 received via dbus. They will either be drawn now or queued to be drawn soon. 182 """ 183 if self._paused: 184 self._queue.append(notif) 185 return 186 187 if qtile.current_window and qtile.current_window.fullscreen: 188 if self.fullscreen != 'show': 189 if self.fullscreen == 'queue': 190 if self._unfullscreen not in hook.subscriptions: 191 hook.subscribe.float_change(self._unfullscreen) 192 self._queue.append(notif) 193 return 194 195 if notif.replaces_id: 196 for popup in self._shown: 197 if notif.replaces_id == popup.replaces_id: 198 self._shown.remove(popup) 199 self._send(notif, popup) 200 self._reposition() 201 return 202 203 if self._hidden: 204 self._send(notif, self._hidden.pop()) 205 else: 206 self._queue.append(notif) 207 208 def _unfullscreen(self): 209 """ 210 Begin displaying of queue notifications after leaving fullscreen. 211 """ 212 if not qtile.current_window.fullscreen: 213 hook.unsubscribe.float_change(self._unfullscreen) 214 self._renotify() 215 216 def _renotify(self): 217 """ 218 If we hold off temporarily on sending notifications and accumulate a queue, we 219 should use this to the queue through self._notify again. 220 """ 221 queue = self._queue.copy() 222 self._queue.clear() 223 while queue: 224 self._notify(queue.pop(0)) 225 226 def _send(self, notif, popup, timeout=None): 227 """ 228 Draw the desired notification using the specified Popup instance. 229 """ 230 text = self._get_text(notif) 231 urgency = notif.hints.get('urgency', 1) 232 self._current_id += 1 233 popup.id = self._current_id 234 if popup not in self._shown: 235 self._shown.append(popup) 236 popup.x, popup.y = self._get_coordinates() 237 icon = self._load_icon(notif) 238 239 popup.background = self.background[urgency] 240 popup.foreground = self.foreground[urgency] 241 popup.clear() 242 243 if icon: 244 popup.draw_image( 245 icon[0], 246 self.horizontal_padding, 247 1 + (self.height - icon[1]) / 2, 248 ) 249 popup.horizontal_padding += self.icon_size + self.horizontal_padding / 2 250 251 for num, line in enumerate(text.split('\n')): 252 popup.text = line 253 y = self.vertical_padding + num * (popup.layout.height + self.line_spacing) 254 popup.draw_text(y=y) 255 if self.border_width: 256 popup.set_border(self.border[urgency]) 257 popup.place() 258 popup.unhide() 259 popup.draw() 260 popup.replaces_id = notif.replaces_id 261 if icon: 262 popup.horizontal_padding = self.horizontal_padding 263 264 if timeout is None: 265 if notif.timeout is None or notif.timeout < 0: 266 timeout = self.timeout[urgency] 267 else: 268 timeout = notif.timeout 269 elif timeout < 0: 270 timeout = self.timeout[urgency] 271 if timeout > 0: 272 qtile.call_later(timeout / 1000, self._close, popup, self._current_id) 273 274 def _get_text(self, notif): 275 summary = '' 276 body = '' 277 app_name = '' 278 if notif.summary: 279 summary = pangocffi.markup_escape_text(notif.summary) 280 if notif.body: 281 body = pangocffi.markup_escape_text(notif.body) 282 if notif.app_name: 283 app_name = pangocffi.markup_escape_text(notif.app_name) 284 return self.format.format(summary=summary, body=body, app_name=app_name) 285 286 def _get_coordinates(self): 287 x, y = self._positions[len(self._shown) - 1] 288 if isinstance(self.screen, int): 289 screen = qtile.screens[self.screen] 290 elif self.screen == 'focus': 291 screen = qtile.current_screen 292 elif self.screen == 'mouse': 293 screen = qtile.find_screen(*qtile.mouse_position) 294 return x + screen.x, y + screen.y 295 296 def _close(self, popup, nid=None): 297 """ 298 Close the specified Popup instance. 299 """ 300 if popup in self._shown: 301 if nid is not None and popup.id != nid: 302 return 303 self._shown.remove(popup) 304 if self._scroll_popup is popup: 305 self._scroll_popup = None 306 self._notif_id = None 307 popup.hide() 308 if self._queue and not self._paused: 309 self._send(self._queue.pop(0), popup) 310 else: 311 self._hidden.append(popup) 312 self._reposition() 313 314 def _reposition(self): 315 for index, shown in enumerate(self._shown): 316 shown.x, shown.y = self._positions[index] 317 shown.place() 318 319 def _load_icon(self, notif): 320 if not notif.app_icon: 321 return None 322 if notif.app_icon in self._icons: 323 return self._icons.get(notif.app_icon) 324 try: 325 img = images.Img.from_path(notif.app_icon) 326 if img.width > img.height: 327 img.resize(width=self.icon_size) 328 else: 329 img.resize(height=self.icon_size) 330 surface, _ = images._decode_to_image_surface( 331 img.bytes_img, img.width, img.height 332 ) 333 self._icons[notif.app_icon] = surface, surface.get_height() 334 except (FileNotFoundError, images.LoadingError, IsADirectoryError) as e: 335 logger.exception(e) 336 self._icons[notif.app_icon] = None 337 return self._icons[notif.app_icon] 338 339 def close(self, qtile=None): 340 """ 341 Close the oldest of all visible popup windows. 342 """ 343 if self._shown: 344 self._close(self._shown[0]) 345 346 def close_all(self, qtile=None): 347 """ 348 Close all popup windows. 349 """ 350 self._queue.clear() 351 while self._shown: 352 self._close(self._shown[0]) 353 354 def prev(self, qtile=None): 355 """ 356 Display the previous notification in the history. 357 """ 358 if notifier.notifications: 359 if self._scroll_popup is None: 360 if self._hidden: 361 self._scroll_popup = self._hidden.pop(0) 362 else: 363 self._scroll_popup = self._shown[0] 364 self._notif_id = len(notifier.notifications) 365 if self._notif_id > 0: 366 self._notif_id -= 1 367 self._send( 368 notifier.notifications[self._notif_id], 369 self._scroll_popup, 370 0 if self.sticky_history else None, 371 ) 372 373 def next(self, qtile=None): 374 """ 375 Display the next notification in the history. 376 """ 377 if self._scroll_popup: 378 if self._notif_id < len(notifier.notifications) - 1: 379 self._notif_id += 1 380 if self._scroll_popup in self._shown: 381 self._shown.remove(self._scroll_popup) 382 self._send( 383 notifier.notifications[self._notif_id], 384 self._scroll_popup, 385 0 if self.sticky_history else None, 386 ) 387 388 def pause(self, qtile=None): 389 """ 390 Pause display of notifications on screen. Notifications will be queued and 391 presented as usual when this is called again. 392 """ 393 if self._paused: 394 self._paused = False 395 self._renotify() 396 else: 397 self._paused = True 398 while self._shown: 399 self._close(self._shown[0])