Skip to content

Commit

Permalink
Provide a D-Bus based StatusNotifierItem
Browse files Browse the repository at this point in the history
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
  • Loading branch information
aperezdc committed Mar 27, 2017
1 parent 627b143 commit 5ba30eb
Show file tree
Hide file tree
Showing 3 changed files with 204 additions and 68 deletions.
4 changes: 2 additions & 2 deletions revolt/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
263 changes: 199 additions & 64 deletions revolt/statusicon.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,65 +2,161 @@
# -*- coding: utf-8 -*-
# vim:fenc=utf-8
#
# Copyright © 2016 Adrian Perez <aperez@igalia.com>
# Copyright © 2016-2017 Adrian Perez <aperez@igalia.com>
#
# 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("<b>Revolt</b>")
else:
self._icon.set_tooltip_markup("<b>Revolt</b><br>{!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
Expand All @@ -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'
Expand All @@ -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()
5 changes: 3 additions & 2 deletions revolt/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit 5ba30eb

Please sign in to comment.