From 5ba30eb2ab8061aabef274eb6ba58d4bfdadc9b1 Mon Sep 17 00:00:00 2001 From: Adrian Perez de Castro Date: Mon, 27 Mar 2017 18:52:51 +0300 Subject: [PATCH] Provide a D-Bus based StatusNotifierItem Implements a StatusNotifierItem/AppIndicator using the excellent StatusNotifier implementation (see https://jjacky.com/statusnotifier/), which is added as a runtime dependency. If StatusNotifier is not available, or the desktop environment does not support SNI/AppIndicator, then the existing GtkStatusNotifier tray icon is used as a fallback. Fixes #50 --- revolt/app.py | 4 +- revolt/statusicon.py | 263 ++++++++++++++++++++++++++++++++----------- revolt/window.py | 5 +- 3 files changed, 204 insertions(+), 68 deletions(-) diff --git a/revolt/app.py b/revolt/app.py index a9143f6..10ae525 100644 --- a/revolt/app.py +++ b/revolt/app.py @@ -8,7 +8,7 @@ from os import environ from gi.repository import Gtk, Gio -from .statusicon import SysTrayStatusIcon +from .statusicon import StatusIcon from .window import MainWindow DEFAULT_APP_ID = "org.perezdecastro.Revolt" @@ -59,7 +59,7 @@ def __on_startup(self, app): gtk_settings = Gtk.Settings.get_default() gtk_settings.set_property("gtk-dialogs-use-header", self.settings.get_boolean("use-header-bar")) - self.statusicon = SysTrayStatusIcon(self, 'disconnected') + self.statusicon = StatusIcon(self) self.__action("quit", lambda *arg: self.quit()) self.__action("about", self.__on_app_about) self.__action("preferences", self.__on_app_preferences) diff --git a/revolt/statusicon.py b/revolt/statusicon.py index 9035869..c2a4889 100644 --- a/revolt/statusicon.py +++ b/revolt/statusicon.py @@ -2,65 +2,161 @@ # -*- coding: utf-8 -*- # vim:fenc=utf-8 # -# Copyright © 2016 Adrian Perez +# Copyright © 2016-2017 Adrian Perez # # Distributed under terms of the GPLv3 license. from gi.repository import Gtk, GLib from .util import cachedproperty +import enum import os -class SysTrayStatusIcon(object): +class Status(enum.Enum): + DISCONNECTED = "disconnected" + CONNECTED = "connected" + BLINKING = "blinking" + + +class StatusIconImpl(object): + def __init__(self, delegate): + self.delegate = delegate + + def __del__(self): + self.delegate = None + + def set_tooltip(self, text): + raise NotImplementedError + + def set_status(self, status): + raise NotImplementedError + + +class StatusIconImplSNI(StatusIconImpl): + ICON_PIXBUF_SIZE = 64 # This seems to be a reasonable size for all DEs. + + def __init__(self, delegate, context_menu, app, failure_callback): + super().__init__(delegate) + try: + import gi + gi.require_version("StatusNotifier", "1.0") + from gi.repository import StatusNotifier + except ImportError: + failure_callback(self) + return + + if hasattr(StatusNotifier.Icon, "ATTENTION_ICON"): + self.SNI_ATTENTION_ICON = StatusNotifier.Icon.ATTENTION_ICON + self.SNI_ACTIVE_ICON = StatusNotifier.Icon.ICON + else: + self.SNI_ATTENTION_ICON = StatusNotifier.Icon.STATUS_NOTIFIER_ATTENTION_ICON + self.SNI_ACTIVE_ICON = StatusNotifier.Icon.STATUS_NOTIFIER_ICON + self.SNI_ATTENTION = StatusNotifier.Status.NEEDS_ATTENTION + self.SNI_ACTIVE = StatusNotifier.Status.ACTIVE + + theme = Gtk.IconTheme.get_default() + self._offline_icon_pixbuf = theme.load_icon(app.get_application_id(), + self.ICON_PIXBUF_SIZE, + Gtk.IconLookupFlags.FORCE_SVG | + Gtk.IconLookupFlags.FORCE_SYMBOLIC) + self._attention_icon_pixbuf = theme.load_icon("revolt-status-blink", + self.ICON_PIXBUF_SIZE, + Gtk.IconLookupFlags.FORCE_SVG | + Gtk.IconLookupFlags.FORCE_SYMBOLIC) + self._online_icon_pixbuf = theme.load_icon("revolt-status-online", + self.ICON_PIXBUF_SIZE, + Gtk.IconLookupFlags.FORCE_SVG | + Gtk.IconLookupFlags.FORCE_SYMBOLIC) + + self._failure_callback = failure_callback + self._sni = StatusNotifier.Item.new_from_pixbuf(app.get_application_id(), + StatusNotifier.Category.COMMUNICATIONS, + self._offline_icon_pixbuf) + if not self._sni.set_context_menu(context_menu): + # TODO: No DbusMenu support built into StatusIcon, we need to handle the + # "context-menu" signal ourselves. For now, fallback to use GtkStatusIcon + print("StatusNotifier does not support DbusMenu, falling back to GtkStatusIcon") + failure_callback(self) + return + + self._sni.connect("registration-failed", self.__on_registration_failed) + #self._sni.connect("context-menu", self.__on_context_menu) + self._sni.connect("activate", self.__on_activate) + self._sni.set_from_pixbuf(self.SNI_ATTENTION_ICON, self._attention_icon_pixbuf) + self._sni.set_title("Revolt") + self._sni.set_status(self.SNI_ACTIVE) + self._sni.set_item_is_menu(False) + self._sni.freeze_tooltip() + self._sni.set_tooltip_title("Revolt") + self._sni.thaw_tooltip() + self._sni.register() + + def set_status(self, status): + if status is Status.BLINKING: + self._sni.set_status(self.SNI_ATTENTION) + else: + self._sni.set_status(self.SNI_ACTIVE) + if status is Status.CONNECTED: + self._sni.set_from_pixbuf(self.SNI_ACTIVE_ICON, self._online_icon_pixbuf) + elif status is Status.DISCONNECTED: + self._sni.set_from_pixbuf(self.SNI_ACTIVE_ICON, self._offline_icon_pixbuf) + else: + assert False, "Unrechable" + + def __on_registration_failed(self, sni, error): + assert sni == self._sni + print("StatusNotifier registration failed, falling back to GtkStatusIcon") + self._failure_callback(self) + + def __on_activate(self, sni, x, y): + assert sni == self._sni + self.delegate.on_icon_activate(self) + + def set_tooltip(self, text): + self._sni.freeze_tooltip() + self._sni.set_tooltip_body("" if text is None else text) + self._sni.thaw_tooltip() + + +class StatusIconImplGSI(StatusIconImpl): ICON_STATUS_NAMES = { - "disconnected": "", - "connected": "-status-online", + Status.DISCONNECTED.value: "", + Status.CONNECTED.value: "-status-online", "flip": "-status-blink", "flop": "" } - def __init__(self, app, initial_status): - self._tooltip_text_no_notifications = "Revolt: 0 notifications" - self.__app = app + def __init__(self, delegate, context_menu, app): + super().__init__(delegate) + self._status = Status.DISCONNECTED + self._contextmenu = context_menu self._size = 16 self._flipflop = True self._blinkmilliseconds = 500 - self._icon = self.__create_icon() - self._contextmenu.insert_action_group("app", self.__app) self._icondata = {} self.__load_icons(self._size) - self.set_status(initial_status) - self.clear_notifications() + self._icon = Gtk.StatusIcon() + self._icon.set_visible(True) + self._icon.set_property("has-tooltip", True) + self._icon.set_property("title", "Revolt") + self._icon.connect("activate", self.__on_activate) + self._icon.connect("popup-menu", self.__on_popup_menu) + self._icon.connect("size-changed", self.__on_icon_size_change) - @cachedproperty - def _contextmenu(self): - model = self.__app.get_menu_by_id("app-menu") - if model is None: - # If showing the application menu in the GNOME Shell top bar is - # disabled, then GtkApplication won't load gtk/menus-appmenu.ui - # automatically, but we still need it for the context menu. - (model,) = self.__app._build("gtk/menus-appmenu.ui", "app-menu") - return Gtk.Menu.new_from_model(model) - - def __add_notification_tooltip_text(self, text): - if self._tooltip_text == self._tooltip_text_no_notifications: - self._tooltip_text = "" - self._tooltip_text += text - self._tooltip_text += "\n" + def set_tooltip(self, text): + if text is None: + self._icon.set_tooltip_text("Revolt") + else: + self._icon.set_tooltip_markup("Revolt
{!s}".format(text)) - def __clear_notification_tooltip_text(self): - self._tooltip_text = self._tooltip_text_no_notifications - - def __create_icon(self): - icon = Gtk.StatusIcon() - icon.set_visible(True) - icon.set_property('has-tooltip', True) - icon.set_property('title', 'Revolt') - icon.connect('activate', self.__on_left_click) - icon.connect('popup-menu', self.__on_right_click) - icon.connect('query-tooltip', self.__on_query_tooltip) - icon.connect('size-changed', self.__on_icon_size_change) - return icon + def set_status(self, status): + if status is Status.BLINKING: + # We only want one blink callback active at a time. + if self._status is not Status.BLINKING: + GLib.timeout_add(self._blinkmilliseconds, self.__blink) + else: + GLib.timeout_add(2 * self._blinkmilliseconds, self.__draw_icon, status) + self._status = status def __load_icons(self, size): self._size = size @@ -73,27 +169,25 @@ def __load_icons(self, size): def __draw_icon(self, status=None): if status is None: - status = self.status - if status == "blinking": + status = self._status + if status is Status.BLINKING: if self._flipflop: self._icon.set_from_pixbuf(self._icondata["flip"]) else: self._icon.set_from_pixbuf(self._icondata["flop"]) else: - self._icon.set_from_pixbuf(self._icondata[status]) + self._icon.set_from_pixbuf(self._icondata[status.value]) return False - def __on_left_click(self, widget): - self.clear_notifications() - self.__app.show() + def __on_activate(self, icon): + assert icon == self._icon + self.delegate.on_icon_activate(self) - def __on_right_click(self, icon, button, time): + def __on_popup_menu(self, icon, button, time): + assert icon == self._icon self._contextmenu.show_all() self._contextmenu.popup(None, None, None, self._icon, button, time) - def __on_query_tooltip(self, widget, x, y, keyboard_mode, tooltip): - self._icon.set_tooltip_text(self._tooltip_text) - def __on_icon_size_change(self, statusicon, size): if size > 31: icon_size = '32' @@ -113,25 +207,66 @@ def __on_icon_size_change(self, statusicon, size): def __blink(self): self._flipflop = not self._flipflop self.__draw_icon() - return self.status == "blinking" + return self.status is Status.BLINKING + + +class StatusIcon(object): + def __init__(self, app, initial_status=Status.DISCONNECTED): + self.status = Status(initial_status) + self.__app = app + self.__tooltip = None + # Try using StatusNotifier first + self._contextmenu.insert_action_group("app", app) + self._impl = StatusIconImplSNI(self, self._contextmenu, app, self.__sni_failed) + self.__configure_impl() + + def __sni_failed(self, sni_impl): + # Use the (deprecated) GtkStatusIcon as fallback + self._impl = StatusIconImplGSI(self, self._contextmenu, self.__app) + self.__configure_impl() + + def __configure_impl(self): + self._impl.set_tooltip(None) + self.clear_notifications() + + @cachedproperty + def _contextmenu(self): + model = self.__app.get_menu_by_id("app-menu") + if model is None: + # If showing the application menu in the GNOME Shell top bar is + # disabled, then GtkApplication won't load gtk/menus-appmenu.ui + # automatically, but we still need it for the context menu. + (model,) = self.__app._build("gtk/menus-appmenu.ui", "app-menu") + return Gtk.Menu.new_from_model(model) + + def __add_notification_tooltip_text(self, text): + if self.__tooltip is None: + self.__tooltip = text + else: + self.__tooltip += "\n" + self.__tooltip += text + self._impl.set_tooltip(self.__tooltip) + + def __clear_notification_tooltip_text(self): + self._impl.set_tooltip(None) + self.__tooltip = None + + def set_status(self, status): + status = Status(status) + if status is not self.status: + self.status = status + self._impl.set_status(self.status) def add_notification(self, text): self.__add_notification_tooltip_text(text) - self.set_status("blinking") + self.set_status(Status.BLINKING) def clear_notifications(self): self.__clear_notification_tooltip_text() - if self.status == "blinking": - self.set_status("connected") + self.set_status(Status.CONNECTED) - def set_status(self, status): - valid_status = ["disconnected", "connected", "blinking"] - if status not in valid_status: - raise ValueError("Status %s is not valid. Allowed values are: %s" % (status, valid_status)) - if status == "blinking": - # We only want one blink callback active at a time. - if self.status != "blinking": - GLib.timeout_add(self._blinkmilliseconds, self.__blink) - else: - GLib.timeout_add(2 * self._blinkmilliseconds, self.__draw_icon, status) - self.status = status + # Delegate methods. + def on_icon_activate(self, icon_impl): + print("StatusIcon activated") + self.clear_notifications() + self.__app.show() diff --git a/revolt/window.py b/revolt/window.py index 6b41882..9711d87 100644 --- a/revolt/window.py +++ b/revolt/window.py @@ -8,6 +8,7 @@ from gi.repository import GLib, Gtk, Gio, WebKit2, GObject from .util import cachedproperty, show_uri +from . import statusicon class MainWindow(Gtk.ApplicationWindow): @@ -140,10 +141,10 @@ def __on_has_toplevel_focus_changed(self, window, has_focus): def __on_load_changed(self, webview, event): if event == WebKit2.LoadEvent.FINISHED: self.network_busy = False - self.application.statusicon.set_status("connected") + self.application.statusicon.set_status(statusicon.Status.CONNECTED) else: self.network_busy = True - self.application.statusicon.set_status("disconnected") + self.application.statusicon.set_status(statusicon.Status.DISCONNECTED) @cachedproperty def _notification_icon(self):