add system tray module

This commit is contained in:
Christian Schulze
2022-01-29 22:59:35 +11:00
parent 1a436269d1
commit f6c2010216
9 changed files with 631 additions and 1 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
/.idea /.idea
/venv /venv
/nwg-panel.egg-info/ /nwg-panel.egg-info/
/nwg_panel.egg-info/
/build/ /build/
/dist/ /dist/
__pycache__

View File

@@ -13,6 +13,7 @@ taskbars_list = []
scratchpads_list = [] scratchpads_list = []
workspaces_list = [] workspaces_list = []
controls_list = [] controls_list = []
tray_list = []
config_dir = "" config_dir = ""
dwl_data_file = None dwl_data_file = None
dwl_instances = [] dwl_instances = []

View File

@@ -50,6 +50,7 @@ from nwg_panel.modules.menu_start import MenuStart
dir_name = os.path.dirname(__file__) dir_name = os.path.dirname(__file__)
from nwg_panel import common from nwg_panel import common
from nwg_panel.modules import sni_system_tray
sway = os.getenv('SWAYSOCK') is not None sway = os.getenv('SWAYSOCK') is not None
if sway: if sway:
@@ -72,6 +73,7 @@ def signal_handler(sig, frame):
desc = {2: "SIGINT", 15: "SIGTERM", 10: "SIGUSR1"} desc = {2: "SIGINT", 15: "SIGTERM", 10: "SIGUSR1"}
if sig == 2 or sig == 15: if sig == 2 or sig == 15:
print("Terminated with {}".format(desc[sig])) print("Terminated with {}".format(desc[sig]))
sni_system_tray.deinit_tray()
Gtk.main_quit() Gtk.main_quit()
elif sig == sig_dwl: elif sig == sig_dwl:
refresh_dwl() refresh_dwl()
@@ -218,6 +220,14 @@ def instantiate_content(panel, container, content_list, icons_path=""):
else: else:
print("{} data file not found".format(common.dwl_data_file)) 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(): def main():
common.config_dir = get_config_dir() common.config_dir = get_config_dir()
@@ -540,6 +550,9 @@ def main():
common.outputs_num = len(common.outputs) common.outputs_num = len(common.outputs)
Gdk.threads_add_timeout(GLib.PRIORITY_DEFAULT_IDLE, 200, check_tree) 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() Gtk.main()

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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__ = """
<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
<interface name="org.kde.StatusNotifierWatcher">
<annotation name="org.gtk.GDBus.C.Name" value="Watcher" />
<method name="RegisterStatusNotifierItem">
<annotation name="org.gtk.GDBus.C.Name" value="RegisterItem" />
<arg name="service" type="s" direction="in"/>
</method>
<method name="RegisterStatusNotifierHost">
<annotation name="org.gtk.GDBus.C.Name" value="RegisterHost" />
<arg name="service" type="s" direction="in"/>
</method>
<property name="RegisteredStatusNotifierItems" type="as" access="read">
<annotation name="org.gtk.GDBus.C.Name" value="RegisteredItems" />
<annotation name="org.qtproject.QtDBus.QtTypeName.Out0" value="QStringList"/>
</property>
<property name="IsStatusNotifierHostRegistered" type="b" access="read">
<annotation name="org.gtk.GDBus.C.Name" value="IsHostRegistered" />
</property>
<property name="ProtocolVersion" type="i" access="read"/>
<signal name="StatusNotifierItemRegistered">
<annotation name="org.gtk.GDBus.C.Name" value="ItemRegistered" />
<arg type="s" direction="out" name="service" />
</signal>
<signal name="StatusNotifierItemUnregistered">
<annotation name="org.gtk.GDBus.C.Name" value="ItemUnregistered" />
<arg type="s" direction="out" name="service" />
</signal>
<signal name="StatusNotifierHostRegistered">
<annotation name="org.gtk.GDBus.C.Name" value="HostRegistered" />
</signal>
<signal name="StatusNotifierHostUnregistered">
<annotation name="org.gtk.GDBus.C.Name" value="HostUnregistered" />
</signal>
</interface>
</node>
"""
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

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
PyGObject~=3.42.0
psutil~=5.9.0
i3ipc~=2.2.1
setuptools~=57.0.0
dasbus~=1.6