From f6c2010216e4fa8d31591a21731503c0c937ae5b Mon Sep 17 00:00:00 2001 From: Christian Schulze Date: Sat, 29 Jan 2022 22:59:35 +1100 Subject: [PATCH] add system tray module --- .gitignore | 4 +- nwg_panel/common.py | 1 + nwg_panel/main.py | 13 + nwg_panel/modules/sni_system_tray/__init__.py | 20 ++ nwg_panel/modules/sni_system_tray/host.py | 128 ++++++++++ nwg_panel/modules/sni_system_tray/item.py | 96 ++++++++ nwg_panel/modules/sni_system_tray/tray.py | 135 ++++++++++ nwg_panel/modules/sni_system_tray/watcher.py | 230 ++++++++++++++++++ requirements.txt | 5 + 9 files changed, 631 insertions(+), 1 deletion(-) create mode 100644 nwg_panel/modules/sni_system_tray/__init__.py create mode 100644 nwg_panel/modules/sni_system_tray/host.py create mode 100644 nwg_panel/modules/sni_system_tray/item.py create mode 100644 nwg_panel/modules/sni_system_tray/tray.py create mode 100644 nwg_panel/modules/sni_system_tray/watcher.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index ffacfd2..77ab581 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /.idea /venv /nwg-panel.egg-info/ +/nwg_panel.egg-info/ /build/ -/dist/ \ No newline at end of file +/dist/ +__pycache__ \ No newline at end of file diff --git a/nwg_panel/common.py b/nwg_panel/common.py index 9a094aa..bd995e8 100644 --- a/nwg_panel/common.py +++ b/nwg_panel/common.py @@ -13,6 +13,7 @@ taskbars_list = [] scratchpads_list = [] workspaces_list = [] controls_list = [] +tray_list = [] config_dir = "" dwl_data_file = None dwl_instances = [] diff --git a/nwg_panel/main.py b/nwg_panel/main.py index 5ff42f2..e973bce 100644 --- a/nwg_panel/main.py +++ b/nwg_panel/main.py @@ -50,6 +50,7 @@ from nwg_panel.modules.menu_start import MenuStart dir_name = os.path.dirname(__file__) from nwg_panel import common +from nwg_panel.modules import sni_system_tray sway = os.getenv('SWAYSOCK') is not None if sway: @@ -72,6 +73,7 @@ def signal_handler(sig, frame): desc = {2: "SIGINT", 15: "SIGTERM", 10: "SIGUSR1"} if sig == 2 or sig == 15: print("Terminated with {}".format(desc[sig])) + sni_system_tray.deinit_tray() Gtk.main_quit() elif sig == sig_dwl: refresh_dwl() @@ -218,6 +220,14 @@ def instantiate_content(panel, container, content_list, icons_path=""): else: print("{} data file not found".format(common.dwl_data_file)) + if item == "tray": + tray_settings = {} + if "tray-settings" in panel: + tray_settings = panel["tray-settings"] + tray = sni_system_tray.Tray(tray_settings, icons_path) + common.tray_list.append(tray) + container.pack_start(tray, False, False, panel["items-padding"]) + def main(): common.config_dir = get_config_dir() @@ -540,6 +550,9 @@ def main(): common.outputs_num = len(common.outputs) Gdk.threads_add_timeout(GLib.PRIORITY_DEFAULT_IDLE, 200, check_tree) + if len(common.tray_list) > 0: + sni_system_tray.init_tray(common.tray_list) + Gtk.main() diff --git a/nwg_panel/modules/sni_system_tray/__init__.py b/nwg_panel/modules/sni_system_tray/__init__.py new file mode 100644 index 0000000..93edc19 --- /dev/null +++ b/nwg_panel/modules/sni_system_tray/__init__.py @@ -0,0 +1,20 @@ +import typing +from threading import Thread + +from . import host, watcher +from .tray import Tray + + +def init_tray(trays: typing.List[Tray]): + host_thread = Thread(target=host.init, args=[0, trays]) + host_thread.daemon = True + host_thread.start() + + watcher_thread = Thread(target=watcher.init) + watcher_thread.daemon = True + watcher_thread.start() + + +def deinit_tray(): + host.deinit() + watcher.deinit() diff --git a/nwg_panel/modules/sni_system_tray/host.py b/nwg_panel/modules/sni_system_tray/host.py new file mode 100644 index 0000000..0095170 --- /dev/null +++ b/nwg_panel/modules/sni_system_tray/host.py @@ -0,0 +1,128 @@ +import typing +import os + +from dasbus.connection import SessionMessageBus +from dasbus.loop import EventLoop +from dasbus.client.observer import DBusObserver +from dasbus.client.proxy import disconnect_proxy + +from .watcher import WATCHER_SERVICE_NAME, WATCHER_OBJECT_PATH +from .tray import Tray +from .item import StatusNotifierItem + +HOST_SERVICE_NAME_TEMPLATE = "org.kde.StatusNotifierHost-{}-{}" +HOST_OBJECT_PATH_TEMPLATE = "/StatusNotifierHost/{}" + +dasbus_event_loop: typing.Union[EventLoop, None] = None + + +def get_service_name_and_object_path(service: str) -> (str, str): + index = service.find("/") + if index != len(service): + return service[0:index], service[index:] + return service, "/StatusNotifierItem" + + +class StatusNotifierHostInterface(object): + def __init__(self, host_id, trays: typing.List[Tray]): + self.host_id = host_id + self.trays = trays + + self._statusNotifierItems = [] + self.watcher_proxy = None + self.session_bus = SessionMessageBus() + + self.host_service_name = HOST_SERVICE_NAME_TEMPLATE.format(os.getpid(), self.host_id) + self.host_object_path = HOST_OBJECT_PATH_TEMPLATE.format(self.host_id) + self.session_bus.register_service(self.host_service_name) + + self.watcher_service_observer = DBusObserver( + message_bus=self.session_bus, + service_name=WATCHER_SERVICE_NAME + ) + self.watcher_service_observer.service_available.connect( + self.watcher_available_handler + ) + self.watcher_service_observer.service_unavailable.connect( + self.watcher_unavailable_handler + ) + self.watcher_service_observer.connect_once_available() + + def __del__(self): + if self.watcher_proxy is not None: + disconnect_proxy(self.watcher_proxy) + self.watcher_service_observer.disconnect() + self.session_bus.disconnect() + + def watcher_available_handler(self, _observer): + print("StatusNotifierHostInterface -> watcher_available_handler") + self.watcher_proxy = self.session_bus.get_proxy(WATCHER_SERVICE_NAME, WATCHER_OBJECT_PATH) + self.watcher_proxy.StatusNotifierItemRegistered.connect(self.item_registered_handler) + self.watcher_proxy.StatusNotifierItemUnregistered.connect(self.item_unregistered_handler) + self.watcher_proxy.RegisterStatusNotifierHost(self.host_object_path, callback=lambda _: None) + + def watcher_unavailable_handler(self, _observer): + print("StatusNotifierHostInterface -> watcher_unavailable_handler") + self._statusNotifierItems.clear() + disconnect_proxy(self.watcher_proxy) + self.watcher_proxy = None + + def item_registered_handler(self, full_service_service): + print( + "StatusNotifierHostInterface -> item_registered_handler\n full_service_name: {}".format( + full_service_service + ) + ) + service_name, object_path = get_service_name_and_object_path(full_service_service) + if self.find_item(service_name, object_path) is None: + item = StatusNotifierItem(service_name, object_path) + item.set_on_loaded_callback(self.item_loaded_handler) + item.set_on_updated_callback(self.item_updated_handler) + self._statusNotifierItems.append(item) + + def item_unregistered_handler(self, full_service_service): + print( + "StatusNotifierHostInterface -> item_unregistered_handler\n full_service_name: {}".format( + full_service_service + ) + ) + service_name, object_path = get_service_name_and_object_path(full_service_service) + item = self.find_item(service_name, object_path) + if item is not None: + self._statusNotifierItems.remove(item) + for tray in self.trays: + tray.remove_item(item) + + def find_item(self, service_name, object_path) -> typing.Union[StatusNotifierItem, None]: + for item in self._statusNotifierItems: + if item.service_name == service_name and item.object_path == object_path: + return item + else: + return None + + def item_loaded_handler(self, item): + for tray in self.trays: + tray.add_item(item) + + def item_updated_handler(self, item, changed_properties): + for tray in self.trays: + tray.update_item(item, changed_properties) + + +def init(host_id, trays: typing.List[Tray]): + _status_notifier_host_interface = StatusNotifierHostInterface(host_id, trays) + + global dasbus_event_loop + if dasbus_event_loop is None: + print("host.init(): running dasbus.EventLoop") + dasbus_event_loop = EventLoop() + dasbus_event_loop.run() + + +def deinit(): + global dasbus_event_loop + if dasbus_event_loop is not None: + print("host.deinit(): quitting dasbus.EventLoop") + dasbus_event_loop.quit() + if dasbus_event_loop is not None: + dasbus_event_loop = None diff --git a/nwg_panel/modules/sni_system_tray/item.py b/nwg_panel/modules/sni_system_tray/item.py new file mode 100644 index 0000000..abb33f2 --- /dev/null +++ b/nwg_panel/modules/sni_system_tray/item.py @@ -0,0 +1,96 @@ +from dasbus.connection import SessionMessageBus +from dasbus.client.observer import DBusObserver +from dasbus.client.proxy import disconnect_proxy + +PROPERTIES = [ + "Id", + "Category", + "Title", + "Status", + "WindowId", + "IconName", + "IconPixmap", + "OverlayIconName", + "OverlayIconPixmap", + "AttentionIconName", + "AttentionIconPixmap", + "AttentionMovieName", + "ToolTip", + "IconThemePath", + "ItemIsMenu", + "Menu" +] + + +class StatusNotifierItem(object): + def __init__(self, service_name, object_path): + self.service_name = service_name + self.object_path = object_path + self.on_loaded_callback = None + self.on_updated_callback = None + self.session_bus = SessionMessageBus() + self.properties = { + "ItemIsMenu": True + } + self.item_proxy = None + + self.item_observer = DBusObserver( + message_bus=self.session_bus, + service_name=self.service_name + ) + self.item_observer.service_available.connect( + self.item_available_handler + ) + self.item_observer.service_unavailable.connect( + self.item_unavailable_handler + ) + self.item_observer.connect_once_available() + + def __del__(self): + if self.item_proxy is not None: + disconnect_proxy(self.item_proxy) + self.item_observer.disconnect() + self.session_bus.disconnect() + + def item_available_handler(self, _observer): + self.item_proxy = self.session_bus.get_proxy(self.service_name, self.object_path) + self.item_proxy.PropertiesChanged.connect( + lambda _if, changed_properties, _invalid: self.change_handler(list(changed_properties)) + ) + self.item_proxy.NewTitle.connect( + lambda _title: self.change_handler(["Title"]) + ) + self.item_proxy.NewIcon.connect( + lambda _icon_name, _icon_pixmap: self.change_handler(["IconName", "IconPixmap"]) + ) + self.item_proxy.NewAttentionIcon.connect( + lambda _icon_name, _icon_pixmap: self.change_handler(["AttentionIconName", "AttentionIconPixmap"]) + ) + self.item_proxy.NewIconThemePath.connect( + lambda _icon_theme_path: self.change_handler(["IconThemePath"]) + ) + self.item_proxy.NewStatus.connect( + lambda _status: self.change_handler(["Status"]) + ) + for name in PROPERTIES: + if hasattr(self.item_proxy, name): + self.properties[name] = getattr(self.item_proxy, name) + if self.on_loaded_callback is not None: + self.on_loaded_callback(self) + + def item_unavailable_handler(self, _observer): + disconnect_proxy(self.item_proxy) + self.item_proxy = None + + def change_handler(self, changed_properties: list[str]): + if len(changed_properties) > 0: + for name, value in changed_properties: + self.properties[name] = value + if self.on_updated_callback is not None: + self.on_updated_callback(self, changed_properties) + + def set_on_loaded_callback(self, callback): + self.on_loaded_callback = callback + + def set_on_updated_callback(self, callback): + self.on_updated_callback = callback diff --git a/nwg_panel/modules/sni_system_tray/tray.py b/nwg_panel/modules/sni_system_tray/tray.py new file mode 100644 index 0000000..ff324c9 --- /dev/null +++ b/nwg_panel/modules/sni_system_tray/tray.py @@ -0,0 +1,135 @@ +import os +import sys +import gi + +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk, GLib, GdkPixbuf + +from nwg_panel.tools import check_key, get_config_dir +from .item import StatusNotifierItem + + +def load_icon(image, icon_name, icon_size, icons_path=""): + icon_theme = Gtk.IconTheme.get_default() + search_path = icon_theme.get_search_path() + if icons_path: + search_path.append(icons_path) + icon_theme.set_search_path(search_path) + if icon_theme.has_icon(icon_name): + pixbuf = icon_theme.load_icon(icon_name, icon_size, Gtk.IconLookupFlags.FORCE_SIZE) + elif icon_theme.has_icon(icon_name.lower()): + pixbuf = icon_theme.load_icon(icon_name.lower(), icon_size, Gtk.IconLookupFlags.FORCE_SIZE) + else: + try: + pixbuf = icon_theme.load_icon(icon_name, icon_size, Gtk.IconLookupFlags.FORCE_SIZE) + except GLib.GError: + print( + "tray.update_icon -> icon not found\n icon_name: {}\n search_path: {}".format( + icon_name, + search_path + ), + file=sys.stderr + ) + path = os.path.join(get_config_dir(), "icons_light/icon-missing.svg") + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(path, icon_size, icon_size) + # TODO: if image height is different to icon_size, resize to match, while maintaining + # aspect ratio. Width can be ignored. + image.set_from_pixbuf(pixbuf) + + +def update_icon(image, item, icon_size, icon_path): + if "IconThemePath" in item.properties: + icon_path = item.properties["IconThemePath"] + load_icon(image, item.properties["IconName"], icon_size, icon_path) + + +def update_status(event_box, item): + if "Status" in item.properties: + status = item.properties["Status"].lower() + event_box.set_visible(status != "passive") + event_box_style = event_box.get_style_context() + for class_name in event_box_style.list_classes(): + event_box_style.remove_class(class_name) + if status == "needsattention": + event_box_style.add_class("needs-attention") + event_box_style.add_class(status) + + +class Tray(Gtk.EventBox): + def __init__(self, settings, icons_path=""): + self.settings = settings + self.icons_path = icons_path + Gtk.EventBox.__init__(self) + + check_key(settings, "icon-size", 16) + check_key(settings, "root-css-name", "tray") + + self.set_property("name", settings["root-css-name"]) + + self.icon_size = settings["icon-size"] + + self.box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + self.add(self.box) + + self.items = {} + + def add_item(self, item: StatusNotifierItem): + print("Tray -> add_item: {}".format(item.properties)) + full_service_name = "{}{}".format(item.service_name, item.object_path) + if full_service_name not in self.items: + event_box = Gtk.EventBox() + image = Gtk.Image() + + if "IconPixmap" in item.properties: + # TODO: handle loading pixbuf from dbus + pass + else: + update_icon(image, item, self.icon_size, self.icons_path) + + if "Tooltip" in item.properties: + # TODO: handle tooltip variant type + pass + + if "Title" in item.properties: + image.set_tooltip_markup(item.properties["Title"]) + + update_status(event_box, item) + + event_box.add(image) + self.box.pack_start(event_box, False, False, 6) + self.box.show_all() + + self.items[full_service_name] = { + "event_box": event_box, + "image": image, + "item": item + } + + def update_item(self, item: StatusNotifierItem, changed_properties: list[str]): + full_service_name = "{}{}".format(item.service_name, item.object_path) + event_box = self.items[full_service_name]["event_box"] + image = self.items[full_service_name]["image"] + + if "IconPixmap" in changed_properties: + # TODO: handle loading pixbuf from dbus + pass + elif "IconThemePath" in changed_properties or "IconName" in changed_properties: + update_icon(image, item, self.icon_size, self.icons_path) + + if "Tooltip" in changed_properties: + # handle tooltip variant type + pass + + if "Title" in changed_properties: + image.set_tooltip_markup(item.properties["Title"]) + + update_status(event_box, item) + + event_box.show_all() + + def remove_item(self, item: StatusNotifierItem): + full_service_name = "{}{}".format(item.service_name, item.object_path) + self.box.remove(self.items[full_service_name]["event_box"]) + self.items.pop(full_service_name) + self.box.show_all() diff --git a/nwg_panel/modules/sni_system_tray/watcher.py b/nwg_panel/modules/sni_system_tray/watcher.py new file mode 100644 index 0000000..c9273a4 --- /dev/null +++ b/nwg_panel/modules/sni_system_tray/watcher.py @@ -0,0 +1,230 @@ +import typing + +from dasbus.connection import SessionMessageBus +from dasbus.loop import EventLoop +from dasbus.signal import Signal +from dasbus.client.observer import DBusObserver +from dasbus.server.interface import accepts_additional_arguments +import dasbus.typing + +WATCHER_SERVICE_NAME = "org.kde.StatusNotifierWatcher" +WATCHER_OBJECT_PATH = "/StatusNotifierWatcher" + +dasbus_event_loop: typing.Union[EventLoop, None] = None + + +class StatusNotifierWatcherInterface(object): + __dbus_xml__ = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + + PropertiesChanged = Signal() + StatusNotifierItemRegistered = Signal() + StatusNotifierItemUnregistered = Signal() + StatusNotifierHostRegistered = Signal() + StatusNotifierHostUnregistered = Signal() + + def __init__(self): + self._statusNotifierItems = [] + self._statusNotifierHosts = [] + self._isStatusNotifierHostRegistered = False + self._protocolVersion = 0 + self.session_bus = SessionMessageBus() + + def __del__(self): + self.session_bus.disconnect() + + @accepts_additional_arguments + def RegisterStatusNotifierItem(self, service, call_info): + print( + "StatusNotifierWatcher -> RegisterStatusNotifierItem\n service: {}\n sender: {}".format( + service, + call_info["sender"] + ) + ) + + # libappindicator sends object path, use sender name and object path + if service[0] == "/": + full_service_name = "{}{}".format(call_info["sender"], service) + + # xembedsniproxy sends item name, use the item from the argument + elif service[0] == ":": + full_service_name = "{}{}".format(service, "/StatusNotifierItem") + + else: + full_service_name = "{}{}".format(call_info["sender"], "/StatusNotifierItem") + + if full_service_name not in self._statusNotifierItems: + item_service_observer = DBusObserver( + message_bus=self.session_bus, + service_name=call_info["sender"] + ) + item_service_observer.service_available.connect( + lambda _observer: self.item_available_handler(full_service_name) + ) + item_service_observer.service_unavailable.connect( + lambda _observer: self.item_unavailable_handler(full_service_name) + ) + item_service_observer.connect_once_available() + else: + print( + ( + "StatusNotifierWatcher -> RegisterStatusNotifierItem: item already registered\n" + " full_service_name: {}" + ).format(full_service_name, service) + ) + + @accepts_additional_arguments + def RegisterStatusNotifierHost(self, service, call_info): + print("StatusNotifierWatcher -> RegisterStatusNotifierHost: {}".format(service)) + if call_info["sender"] not in self._statusNotifierHosts: + host_service_observer = DBusObserver( + message_bus=self.session_bus, + service_name=call_info["sender"] + ) + host_service_observer.service_available.connect( + self.host_available_handler + ) + host_service_observer.service_unavailable.connect( + self.host_unavailable_handler + ) + host_service_observer.connect_once_available() + else: + print( + "StatusNotifierWatcher -> RegisterStatusNotifierHost: host already registered\n service: {}\n sender: {})".format( + service, + call_info["sender"] + ) + ) + + @property + def RegisteredStatusNotifierItems(self) -> list: + print("StatusNotifierWatcher -> RegisteredStatusNotifierItems") + return self._statusNotifierItems + + @property + def IsStatusNotifierHostRegistered(self) -> bool: + print( + "StatusNotifierWatcher -> IsStatusNotifierHostRegistered: {}".format( + str(len(self._statusNotifierHosts) > 0) + ) + ) + return len(self._statusNotifierHosts) > 0 + + @property + def ProtocolVersion(self) -> int: + print("StatusNotifierWatcher -> ProtocolVersion: ".format(str(self._protocolVersion))) + return self._protocolVersion + + def item_available_handler(self, full_service_name): + print( + "StatusNotifierWatcher -> item_available_handler\n full_service_name: {}".format( + full_service_name + ) + ) + self._statusNotifierItems.append(full_service_name) + self.StatusNotifierItemRegistered.emit(full_service_name) + self.PropertiesChanged.emit(WATCHER_SERVICE_NAME, { + "RegisteredStatusNotifierItems": dasbus.typing.get_variant( + dasbus.typing.List[dasbus.typing.Str], + self._statusNotifierItems + ) + }, []) + + def item_unavailable_handler(self, full_service_name): + print( + "StatusNotifierWatcher -> item_unavailable_handler\n full_service_name: {}".format( + full_service_name + ) + ) + if full_service_name in set(self._statusNotifierItems): + self._statusNotifierItems.remove(full_service_name) + self.StatusNotifierItemUnregistered.emit(full_service_name) + self.PropertiesChanged.emit(WATCHER_SERVICE_NAME, { + "RegisteredStatusNotifierItems": dasbus.typing.get_variant( + dasbus.typing.List[dasbus.typing.Str], + self._statusNotifierItems + ) + }, []) + + def host_available_handler(self, observer): + self._statusNotifierHosts.append(observer.service_name) + self.StatusNotifierHostRegistered.emit() + self.PropertiesChanged.emit(WATCHER_SERVICE_NAME, { + "IsStatusNotifierHostRegistered": dasbus.typing.get_variant(dasbus.typing.Bool, True) + }, []) + + def host_unavailable_handler(self, observer): + self._statusNotifierHosts.remove(observer.service_name) + self.StatusNotifierHostUnregistered.emit() + if len(self._statusNotifierHosts) == 0: + self.PropertiesChanged.emit(WATCHER_SERVICE_NAME, { + "IsStatusNotifierHostRegistered": dasbus.typing.get_variant(dasbus.typing.Bool, False) + }, []) + + +def init(): + session_bus = SessionMessageBus() + session_bus.publish_object(WATCHER_OBJECT_PATH, StatusNotifierWatcherInterface()) + session_bus.register_service(WATCHER_SERVICE_NAME) + print("watcher.init(): published {}{} on dbus.".format(WATCHER_SERVICE_NAME, WATCHER_OBJECT_PATH)) + + global dasbus_event_loop + if dasbus_event_loop is None: + print("watcher.init(): running dasbus.EventLoop") + dasbus_event_loop = EventLoop() + dasbus_event_loop.run() + + +def deinit(): + global dasbus_event_loop + if dasbus_event_loop is not None: + print("watcher.deinit(): quitting dasbus.EventLoop") + dasbus_event_loop.quit() + if dasbus_event_loop is not None: + dasbus_event_loop = None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e70f66c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +PyGObject~=3.42.0 +psutil~=5.9.0 +i3ipc~=2.2.1 +setuptools~=57.0.0 +dasbus~=1.6 \ No newline at end of file