My collection of plugins for the Qtile window manager.
git clone https://mcol.xyz/code/qtools
Log | Files | Refs | README

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])