__init__.py

View raw Download
text/x-script.python • 10.98 kiB
Python script, ASCII text executable
        
            
1
#!/usr/bin/env python3
2
"""
3
D-Bus app indicator/system tray for the Panorama panel.
4
Copyright 2025, roundabout-host.com <vlad@roundabout-host.com>
5
6
This program is free software: you can redistribute it and/or modify
7
it under the terms of the GNU General Public Licence as published by
8
the Free Software Foundation, either version 3 of the Licence, or
9
(at your option) any later version.
10
11
This program is distributed in the hope that it will be useful,
12
but WITHOUT ANY WARRANTY; without even the implied warranty of
13
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
GNU General Public Licence for more details.
15
16
You should have received a copy of the GNU General Public Licence
17
along with this program. If not, see <https://www.gnu.org/licenses/>.
18
"""
19
20
from __future__ import annotations
21
import os
22
import traceback
23
import uuid
24
import locale
25
from pathlib import Path
26
27
import gi
28
from ctypes import CDLL
29
CDLL("libdbusmenu-glib.so")
30
gi.require_version("Dbusmenu", "0.4")
31
32
import panorama_panel
33
from gi.repository import Gtk, GLib, Gio, Gdk
34
35
from . import dbusmenu
36
37
module_directory = Path(__file__).resolve().parent
38
locale.bindtextdomain("panorama-indicator-applet", module_directory / "locale")
39
_ = lambda x: locale.dgettext("panorama-indicator-applet", x)
40
41
42
@Gtk.Template(filename=str(module_directory / "panorama-indicator-applet.ui"))
43
class IndicatorOptions(Gtk.Window):
44
__gtype_name__ = "IndicatorOptions"
45
icon_size_adjustment = Gtk.Template.Child()
46
47
def __init__(self, **kwargs):
48
super().__init__(**kwargs)
49
50
self.connect("close-request", lambda *args: self.destroy())
51
52
53
class StatusNotifierService:
54
"""
55
<node>
56
<interface name="org.kde.StatusNotifierHost">
57
</interface>
58
</node>
59
"""
60
61
def __init__(self, applet: IndicatorApplet):
62
self.bus_id = None
63
self.applet = applet
64
self.xml = """
65
<node>
66
<interface name="org.kde.StatusNotifierHost">
67
</interface>
68
</node>
69
"""
70
self.node_info = Gio.DBusNodeInfo.new_for_xml(self.xml)
71
self.iface_info = self.node_info.interfaces[0]
72
73
def export(self, connection: Gio.DBusConnection):
74
self.bus_id = connection.register_object(
75
"/StatusNotifierHost",
76
self.iface_info,
77
self.handle_method_call,
78
None,
79
None,
80
)
81
82
def unexport(self, connection: Gio.DBusConnection):
83
connection.unregister_object(self.bus_id)
84
85
def handle_method_call(self, conn, sender, obj_path, iface_name, method, params, invocation):
86
# It has no methods
87
invocation.return_value(None)
88
89
90
class StatusIcon(Gtk.MenuButton):
91
def __init__(self, applet: IndicatorApplet, service: str):
92
super().__init__(has_frame=False)
93
self.applet = applet
94
self.service = service
95
self.icon = Gtk.Image(icon_name="image-missing", pixel_size=self.applet.icon_size)
96
self.set_child(self.icon)
97
self.popover_menu = Gtk.PopoverMenu(flags=Gtk.PopoverMenuFlags.NESTED, has_arrow=False, halign=Gtk.Align.START)
98
self.set_popover(self.popover_menu)
99
panorama_panel.track_popover(self.popover_menu)
100
101
connection, _0, path = self.service.rpartition(":")
102
self.menu = None
103
104
try:
105
self.proxy = Gio.DBusProxy.new_sync(
106
self.applet.connection,
107
Gio.DBusProxyFlags.NONE,
108
None,
109
connection,
110
path,
111
"org.kde.StatusNotifierItem",
112
None
113
)
114
self.applet.item_id += 1
115
icon_name = self.proxy.get_cached_property("IconName")
116
if icon_name:
117
icon_name = icon_name.unpack()
118
self.icon.set_from_icon_name(icon_name)
119
menu = self.proxy.get_cached_property("Menu")
120
if menu:
121
self.menu = dbusmenu.CanonicalMenuModel()
122
self.menu.connect("menu_changed", lambda x: panorama_panel.add_icons_to_menu(self.popover_menu, Gtk.IconSize.NORMAL))
123
self.menu.connect_dbus(connection, menu.get_string(), f"ac{self.applet.item_id}")
124
self.insert_action_group(f"ac{self.applet.item_id}", self.menu.actions)
125
self.popover_menu.set_menu_model(self.menu.menu)
126
127
# TODO: NewTitle etc.
128
self.applet.connection.signal_subscribe(
129
connection,
130
"org.kde.StatusNotifierItem",
131
"NewIcon",
132
path,
133
None,
134
Gio.DBusSignalFlags.NONE,
135
self.on_icon_change,
136
)
137
except Exception as e:
138
print(f"Could not get properties from {service}: {e}")
139
traceback.print_exc()
140
141
def on_icon_change(self, *args):
142
icon_name = self.proxy.call_sync(
143
"org.freedesktop.DBus.Properties.Get",
144
GLib.Variant("(ss)", ("org.kde.StatusNotifierItem", "IconName")),
145
Gio.DBusCallFlags.NONE,
146
-1,
147
None
148
)
149
if icon_name:
150
self.icon.set_from_icon_name(icon_name.unpack()[0])
151
152
153
class IndicatorApplet(panorama_panel.Applet):
154
name = _("Indicators")
155
description = _("Show application status icons")
156
icon = Gio.ThemedIcon.new("dialog-warning")
157
158
def shutdown(self, app: Gtk.Application):
159
super().shutdown(app)
160
self.service.unexport(self.connection)
161
162
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None, **kwargs):
163
super().__init__(orientation=orientation, config=config or {}, **kwargs)
164
self.items = {}
165
self.item_id = 0
166
167
if config is None:
168
config = {}
169
170
self.icon_size = config.get("icon_size", 24)
171
172
self.context_menu = self.make_context_menu()
173
panorama_panel.track_popover(self.context_menu)
174
175
right_click_controller = Gtk.GestureClick()
176
right_click_controller.set_button(3)
177
right_click_controller.connect("pressed", self.show_context_menu)
178
179
self.add_controller(right_click_controller)
180
181
action_group = Gio.SimpleActionGroup()
182
options_action = Gio.SimpleAction.new("options", None)
183
options_action.connect("activate", self.show_options)
184
action_group.add_action(options_action)
185
self.insert_action_group("applet", action_group)
186
187
self.options_window = None
188
189
self.connection = Gio.bus_get_sync(Gio.BusType.SESSION)
190
191
# Make the host name unique
192
self.bus_name = f"org.kde.StatusNotifierHost-{os.getpid()}-pan-{uuid.uuid4()}"
193
194
self.service = StatusNotifierService(self)
195
self.service.export(self.connection)
196
Gio.bus_own_name(
197
Gio.BusType.SESSION,
198
self.bus_name,
199
Gio.BusNameOwnerFlags.NONE,
200
None,
201
None,
202
None,
203
)
204
205
try:
206
self.watcher = Gio.DBusProxy.new_sync(
207
self.connection,
208
Gio.DBusProxyFlags.NONE,
209
None,
210
"org.kde.StatusNotifierWatcher",
211
"/StatusNotifierWatcher",
212
"org.kde.StatusNotifierWatcher",
213
None,
214
)
215
except Exception as e:
216
print("Could not connect to StatusNotifierWatcher:", e)
217
self.watcher = None
218
219
if self.watcher:
220
try:
221
self.watcher.call_sync(
222
"RegisterStatusNotifierHost",
223
GLib.Variant("(s)", (self.bus_name,)),
224
Gio.DBusCallFlags.NONE,
225
-1,
226
None,
227
)
228
except Exception as e:
229
print("Failed to register host:", e)
230
231
# Add the existing items
232
existing_items = self.watcher.call_sync(
233
"org.freedesktop.DBus.Properties.Get",
234
GLib.Variant("(ss)", ("org.kde.StatusNotifierWatcher", "RegisteredStatusNotifierItems")),
235
Gio.DBusCallFlags.NONE,
236
-1,
237
None
238
)
239
for item in existing_items[0]:
240
self.on_item_registered(item)
241
242
# Watch for new items
243
self.connection.signal_subscribe(
244
None,
245
"org.kde.StatusNotifierWatcher",
246
"StatusNotifierItemRegistered",
247
"/StatusNotifierWatcher",
248
None,
249
Gio.DBusSignalFlags.NONE,
250
self.on_item_registered_signal,
251
)
252
# Watch for vanishing items
253
self.connection.signal_subscribe(
254
None,
255
"org.kde.StatusNotifierWatcher",
256
"StatusNotifierItemUnregistered",
257
"/StatusNotifierWatcher",
258
None,
259
Gio.DBusSignalFlags.NONE,
260
self.on_item_unregistered_signal,
261
)
262
# TODO: watch for things such as icon changes.
263
264
def on_item_registered_signal(self, conn, sender_name, obj_path, iface_name, signal_name, params):
265
(service,) = params.unpack()
266
self.on_item_registered(service)
267
268
def on_item_unregistered_signal(self, conn, sender_name, obj_path, iface_name, signal_name, params):
269
(service,) = params.unpack()
270
self.on_item_unregistered(service)
271
272
def on_item_registered(self, service):
273
icon = StatusIcon(self, service)
274
self.append(icon)
275
self.items[service] = icon
276
277
def on_item_unregistered(self, service):
278
if service in self.items:
279
self.remove(self.items[service])
280
del self.items[service]
281
282
def make_context_menu(self):
283
menu = Gio.Menu()
284
menu.append(_("Indicator _options"), "applet.options")
285
context_menu = Gtk.PopoverMenu(menu_model=menu, has_arrow=False, halign=Gtk.Align.START, flags=Gtk.PopoverMenuFlags.NESTED)
286
context_menu.set_parent(self)
287
return context_menu
288
289
def show_context_menu(self, gesture, n_presses, x, y):
290
rect = Gdk.Rectangle()
291
rect.x = int(x)
292
rect.y = int(y)
293
rect.width = 1
294
rect.height = 1
295
296
self.context_menu.set_pointing_to(rect)
297
self.context_menu.popup()
298
299
def show_options(self, _0=None, _1=None):
300
if self.options_window is None:
301
self.options_window = IndicatorOptions()
302
self.options_window.icon_size_adjustment.set_value(self.icon_size)
303
self.options_window.icon_size_adjustment.connect("value-changed", self.update_icon_size)
304
305
def reset_window(*args):
306
self.options_window = None
307
308
self.options_window.connect("close-request", reset_window)
309
self.options_window.present()
310
311
def update_icon_size(self, adjustment, *args):
312
self.icon_size = int(adjustment.get_value())
313
item = self.get_first_child()
314
while item is not None:
315
if isinstance(item, StatusIcon):
316
item.icon.set_pixel_size(self.icon_size)
317
item = item.get_next_sibling()
318
319
def get_config(self):
320
return {
321
"icon_size": self.icon_size,
322
}
323