roundabout,
created on Saturday, 18 October 2025, 10:27:19 (1760783239),
received on Saturday, 18 October 2025, 10:27:22 (1760783242)
Author identity: Vlad <vlad.muntoiu@gmail.com>
531b5fb8e89f1288ea5bbde374ce1372235aa578
applets/indicators/__init__.py
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
D-Bus app indicator/system tray for the Panorama panel.
Copyright 2025, roundabout-host.com <vlad@roundabout-host.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public Licence as published by
the Free Software Foundation, either version 3 of the Licence, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public Licence for more details.
You should have received a copy of the GNU General Public Licence
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import os
import traceback
import uuid
import locale
from pathlib import Path
import gi
from ctypes import CDLL
CDLL("libdbusmenu-glib.so")
gi.require_version("Dbusmenu", "0.4")
import panorama_panel
from gi.repository import Gtk, GLib, Gio
from . import dbusmenu
module_directory = Path(__file__).resolve().parent
locale.bindtextdomain("panorama-indicator-applet", module_directory / "locale")
_ = lambda x: locale.dgettext("panorama-indicator-applet", x)
class StatusNotifierService:
"""
<node>
<interface name="org.kde.StatusNotifierHost">
</interface>
</node>
"""
def __init__(self, applet: IndicatorApplet):
self.applet = applet
self.xml = """
<node>
<interface name="org.kde.StatusNotifierHost">
</interface>
</node>
"""
self.node_info = Gio.DBusNodeInfo.new_for_xml(self.xml)
self.iface_info = self.node_info.interfaces[0]
def export(self, connection: Gio.DBusConnection):
connection.register_object(
"/StatusNotifierHost",
self.iface_info,
self.handle_method_call,
None,
None,
)
def handle_method_call(self, conn, sender, obj_path, iface_name, method, params, invocation):
# It has no methods
invocation.return_value(None)
class StatusIcon(Gtk.MenuButton):
def __init__(self, applet: IndicatorApplet, service: str):
super().__init__()
self.applet = applet
self.service = service
label = Gtk.Label(label=service)
self.set_child(label)
print(self.service)
self.popover_menu = Gtk.PopoverMenu(flags=Gtk.PopoverMenuFlags.NESTED)
self.set_popover(self.popover_menu)
connection, _0, path = self.service.rpartition(":")
self.menu = None
try:
proxy = Gio.DBusProxy.new_sync(
self.applet.connection,
Gio.DBusProxyFlags.NONE,
None,
connection,
path,
"org.kde.StatusNotifierItem",
None
)
self.applet.item_id += 1
icon_name = proxy.get_cached_property("IconName")
if icon_name:
icon_name = icon_name.unpack()
self.icon = Gtk.Image.new_from_icon_name(icon_name)
self.set_child(self.icon)
menu = proxy.get_cached_property("Menu")
if menu:
self.menu = dbusmenu.CanonicalMenuModel()
self.menu.connect_dbus(connection, menu.get_string(), f"ac{self.applet.item_id}")
self.insert_action_group(f"ac{self.applet.item_id}", self.menu.actions)
self.popover_menu.set_menu_model(self.menu.menu)
except Exception as e:
print(f"Could not get properties from {service}: {e}")
traceback.print_exc()
class IndicatorApplet(panorama_panel.Applet):
name = _("Indicators")
description = _("Show application status icons")
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
super().__init__(orientation=orientation, config=config or {})
self.items = {}
self.item_id = 0
self.connection = Gio.bus_get_sync(Gio.BusType.SESSION)
# Make the host name unique
self.bus_name = f"org.kde.StatusNotifierHost-{os.getpid()}-pan-{uuid.uuid4()}"
self.service = StatusNotifierService(self)
self.service.export(self.connection)
Gio.bus_own_name(
Gio.BusType.SESSION,
self.bus_name,
Gio.BusNameOwnerFlags.NONE,
None,
None,
None,
)
try:
self.watcher = Gio.DBusProxy.new_sync(
self.connection,
Gio.DBusProxyFlags.NONE,
None,
"org.kde.StatusNotifierWatcher",
"/StatusNotifierWatcher",
"org.kde.StatusNotifierWatcher",
None,
)
except Exception as e:
print("Could not connect to StatusNotifierWatcher:", e)
self.watcher = None
if self.watcher:
try:
self.watcher.call_sync(
"RegisterStatusNotifierHost",
GLib.Variant("(s)", (self.bus_name,)),
Gio.DBusCallFlags.NONE,
-1,
None,
)
except Exception as e:
print("Failed to register host:", e)
# Watch for new items
self.connection.signal_subscribe(
None,
"org.kde.StatusNotifierWatcher",
"StatusNotifierItemRegistered",
"/StatusNotifierWatcher",
None,
Gio.DBusSignalFlags.NONE,
self.on_item_registered_signal,
)
# Watch for vanishing items
self.connection.signal_subscribe(
None,
"org.kde.StatusNotifierWatcher",
"StatusNotifierItemUnregistered",
"/StatusNotifierWatcher",
None,
Gio.DBusSignalFlags.NONE,
self.on_item_unregistered_signal,
)
# TODO: watch for things such as icon changes.
def on_item_registered_signal(self, conn, sender_name, obj_path, iface_name, signal_name, params):
(service,) = params.unpack()
self.on_item_registered(service)
def on_item_unregistered_signal(self, conn, sender_name, obj_path, iface_name, signal_name, params):
(service,) = params.unpack()
self.on_item_unregistered(service)
def on_item_registered(self, service):
icon = StatusIcon(self, service)
self.append(icon)
self.items[service] = icon
def on_item_unregistered(self, service):
if service in self.items:
self.remove(self.items[service])
del self.items[service]
applets/indicators/dbusmenu.py
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""
Translates the com.canonical.dbusmenu protocol to Gio menus.
Based on a C++ implementation.
Thanks to https://github.com/WayfireWM/wf-shell/blob/master/src/panel/widgets/tray/item.hpp
for the C++ version which is Copyright 2025, trigg <https://github.com/trigg>,
MIT/Expat Licence.
Python version: Copyright 2025, roundabout-host.com <vlad@roundabout-host.com>
This file (and no other files, unless otherwise stated) is licensed under the
MIT/Expat Licence:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
import typing
import uuid
from gi.repository import GLib, Gio, GObject, Dbusmenu
class CanonicalMenuModel(GObject.Object):
"""Implements com.canonical.dbusmenu protocol.
Provides a `menu` attribute to get a Gio.Menu for the specified DBus object, and an
`actions` attribute that is a Gio.ActionGroup with the menu actions to insert where needed.
"""
def __init__(self):
"""Generate a disconnected instance."""
super().__init__()
self.prefix = None
self.client = None
self.prefix = ""
self.actions = Gio.SimpleActionGroup()
self.menu: typing.Optional[Gio.Menu] = Gio.Menu()
def connect_dbus(self, dbus_name: str, menu_path: str, prefix: str) -> None:
"""Connect this menu with the specified DBus object.
:param dbus_name: The bus name to use, like ":1.360" or "org.gnome.Fractal"
:param menu_path: The DBus path to the menu. It can be found in the Menu attribute of an
org.kde.StatusNotifierItem.
:param prefix: The prefix to use for action names; use this name for inserting the
action group.
"""
self.prefix = prefix
self.client = Dbusmenu.Client.new(dbus_name, menu_path)
self.client.connect("layout-updated", self._layout_updated)
root = self.client.get_root()
self.reconstitute(root)
def reconstitute(self, root_item: Dbusmenu.Menuitem):
"""Regenerate the menu.
:param root_item: The item to recursively generate from.
"""
self.menu.remove_all()
for action_name in self.actions.list_actions():
self.actions.remove_action(action_name)
self._iterate_children(self.menu, root_item, 0)
def _iterate_children(self, parent_menu, parent, count):
if isinstance(parent, Dbusmenu.Menuitem):
current_section = Gio.Menu()
for child in parent.get_children():
label = child.property_get("label")
if not label:
parent_menu.append_section(None, current_section)
current_section = Gio.Menu()
continue
menu_type = ""
icon_name = ""
toggle_type = ""
if child.property_exist("type"):
menu_type = child.property_get("type")
enabled = child.property_get_bool("enabled")
if child.property_exist("icon-name"):
icon_name = child.property_get("icon-name")
toggle_state = child.property_get_int("toggle-state")
if child.property_exist("toggle-type"):
icon_name = child.property_get("toggle-type")
has_children = bool(child.get_children())
if has_children:
submenu = Gio.Menu()
count = self._iterate_children(submenu, child, count)
current_section.append_submenu(label, submenu)
else:
item = Gio.MenuItem.new(label, "a")
if enabled:
# Difference from C++ version: we don't use the label, to make it easier
# to translate
action_name = f"mi{count}"
action_string = f"{self.prefix}.{action_name}"
if toggle_type == "radio":
item.set_action_and_target_value(action_string, GLib.Variant.new_string(action_name))
boolean_action = Gio.SimpleAction(name=action_name, state=GLib.Variant.new_string(action_name if toggle_state else ""))
# A default argument is needed, otherwise the last item will be used
def on_activate(action, vb, item=child):
data = GLib.Variant.new_int32(0)
item.handle_event("clicked", data, 0)
boolean_action.connect("activate", on_activate)
self.actions.add_action(boolean_action)
elif toggle_type == "check":
item.set_action_and_target_value(action_string, GLib.Variant.new_boolean(toggle_state))
boolean_action = Gio.SimpleAction(name=action_name, state=GLib.Variant.new_boolean(toggle_state))
def on_activate(action, vb, item=child):
data = GLib.Variant.new_int32(0)
item.handle_event("clicked", data, 0)
boolean_action.connect("activate", on_activate)
self.actions.add_action(boolean_action)
else:
item.set_detailed_action(action_string)
action = Gio.SimpleAction(name=action_name)
def on_activate(action, vb, item=child):
data = GLib.Variant.new_int32(0)
item.handle_event("clicked", data, 0)
action.connect("activate", on_activate)
self.actions.add_action(action)
current_section.append_item(item)
count += 1
parent_menu.append_section(None, current_section)
return count
def _layout_updated(self, item):
if self.client is None:
return
root = self.client.get_root()
self.reconstitute(root)
config.yaml
@@ -69,6 +69,7 @@ panels:
icon_size: 24
scroll_direction: 1
volume_step: 0.05
- IndicatorApplet: {}
- ClockApplet:
formatting: '%T, %a %-d %b %Y'
- position: bottom
main.py
@@ -47,7 +47,7 @@ faulthandler.enable()
os.environ["GI_TYPELIB_PATH"] = "/usr/local/lib/x86_64-linux-gnu/girepository-1.0"
from ctypes import CDLL
CDLL('libgtk4-layer-shell.so')
CDLL("libgtk4-layer-shell.so")
import gi
gi.require_version("Gtk", "4.0")
@@ -638,10 +638,13 @@ def load_packages_from_dir(dir_path: Path):
module_name = path.name
spec = importlib.util.spec_from_file_location(module_name, path / "__init__.py")
module = importlib.util.module_from_spec(spec)
module.__package__ = module_name
sys.modules[module_name] = module
spec.loader.exec_module(module)
loaded_modules.append(module)
except Exception as e:
print(f"Failed to load applet module {path.name}: {e}", file=sys.stderr)
traceback.print_exc()
else:
continue