main.py
Python script, ASCII text executable
1
"""
2
Panorama panel: traditional desktop panel using Wayland layer-shell
3
protocol.
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
22
import os
23
import sys
24
import importlib
25
import time
26
import traceback
27
import faulthandler
28
import typing
29
import locale
30
from itertools import accumulate, chain
31
from pathlib import Path
32
import ruamel.yaml as yaml
33
from pywayland.client import Display, EventQueue
34
from pywayland.protocol.wayland.wl_output import WlOutputProxy
35
from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlOutput, WlShm
36
from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import (
37
ZwlrForeignToplevelManagerV1,
38
ZwlrForeignToplevelHandleV1
39
)
40
from pywayland.protocol.wlr_screencopy_unstable_v1 import (
41
ZwlrScreencopyManagerV1,
42
ZwlrScreencopyFrameV1
43
)
44
45
faulthandler.enable()
46
47
os.environ["GI_TYPELIB_PATH"] = "/usr/local/lib/x86_64-linux-gnu/girepository-1.0"
48
49
from ctypes import CDLL
50
CDLL("libgtk4-layer-shell.so")
51
52
import gi
53
gi.require_version("Gtk", "4.0")
54
gi.require_version("Gtk4LayerShell", "1.0")
55
56
from gi.repository import Gtk, GLib, Gtk4LayerShell, Gdk, Gio, GObject
57
from gi.events import GLibEventLoopPolicy
58
59
import ctypes
60
from cffi import FFI
61
ffi = FFI()
62
ffi.cdef("""
63
void * gdk_wayland_display_get_wl_display (void * display);
64
void * gdk_wayland_surface_get_wl_surface (void * surface);
65
void * gdk_wayland_monitor_get_wl_output (void * monitor);
66
""")
67
gtk = ffi.dlopen("libgtk-4.so.1")
68
69
sys.path.insert(0, str((Path(__file__).parent / "shared").resolve()))
70
71
locale.bindtextdomain("panorama-panel", "locale")
72
locale.textdomain("panorama-panel")
73
locale.setlocale(locale.LC_ALL)
74
_ = locale.gettext
75
76
import panorama_panel
77
78
import asyncio
79
asyncio.set_event_loop_policy(GLibEventLoopPolicy())
80
81
module_directory = Path(__file__).resolve().parent
82
83
custom_css = """
84
.panel-flash {
85
animation: flash 333ms ease-in-out 0s 2;
86
}
87
88
@keyframes flash {
89
0% {
90
background-color: initial;
91
}
92
50% {
93
background-color: #ffff0080;
94
}
95
0% {
96
background-color: initial;
97
}
98
}
99
"""
100
101
css_provider = Gtk.CssProvider()
102
css_provider.load_from_data(custom_css)
103
Gtk.StyleContext.add_provider_for_display(
104
Gdk.Display.get_default(),
105
css_provider,
106
100
107
)
108
109
110
@Gtk.Template(filename=str(module_directory / "panel-configurator.ui"))
111
class PanelConfigurator(Gtk.Frame):
112
__gtype_name__ = "PanelConfigurator"
113
114
panel_size_adjustment: Gtk.Adjustment = Gtk.Template.Child()
115
monitor_number_adjustment: Gtk.Adjustment = Gtk.Template.Child()
116
top_position_radio: Gtk.CheckButton = Gtk.Template.Child()
117
bottom_position_radio: Gtk.CheckButton = Gtk.Template.Child()
118
left_position_radio: Gtk.CheckButton = Gtk.Template.Child()
119
right_position_radio: Gtk.CheckButton = Gtk.Template.Child()
120
autohide_switch: Gtk.Switch = Gtk.Template.Child()
121
122
def __init__(self, panel: Panel, **kwargs):
123
super().__init__(**kwargs)
124
self.panel = panel
125
self.panel_size_adjustment.set_value(panel.size)
126
127
match self.panel.position:
128
case Gtk.PositionType.TOP:
129
self.top_position_radio.set_active(True)
130
case Gtk.PositionType.BOTTOM:
131
self.bottom_position_radio.set_active(True)
132
case Gtk.PositionType.LEFT:
133
self.left_position_radio.set_active(True)
134
case Gtk.PositionType.RIGHT:
135
self.right_position_radio.set_active(True)
136
137
self.top_position_radio.panel_position_target = Gtk.PositionType.TOP
138
self.bottom_position_radio.panel_position_target = Gtk.PositionType.BOTTOM
139
self.left_position_radio.panel_position_target = Gtk.PositionType.LEFT
140
self.right_position_radio.panel_position_target = Gtk.PositionType.RIGHT
141
self.autohide_switch.set_active(self.panel.autohide)
142
143
@Gtk.Template.Callback()
144
def update_panel_size(self, adjustment: Gtk.Adjustment):
145
if not self.get_root():
146
return
147
self.panel.set_size(int(adjustment.get_value()))
148
149
self.panel.get_application().save_config()
150
151
@Gtk.Template.Callback()
152
def toggle_autohide(self, switch: Gtk.Switch, value: bool):
153
if not self.get_root():
154
return
155
self.panel.set_autohide(value, self.panel.hide_time)
156
157
self.panel.get_application().save_config()
158
159
@Gtk.Template.Callback()
160
def move_panel(self, button: Gtk.CheckButton):
161
if not self.get_root():
162
return
163
if not button.get_active():
164
return
165
166
self.panel.set_position(button.panel_position_target)
167
self.update_panel_size(self.panel_size_adjustment)
168
169
# Make the applets aware of the changed orientation
170
for area in (self.panel.left_area, self.panel.centre_area, self.panel.right_area):
171
applet = area.get_first_child()
172
while applet:
173
applet.set_orientation(self.panel.get_orientation())
174
applet.set_panel_position(self.panel.position)
175
applet.queue_resize()
176
applet = applet.get_next_sibling()
177
178
self.panel.get_application().save_config()
179
180
@Gtk.Template.Callback()
181
def move_to_monitor(self, adjustment: Gtk.Adjustment):
182
if not self.get_root():
183
return
184
app: PanoramaPanel = self.get_root().get_application()
185
monitor = app.monitors[int(self.monitor_number_adjustment.get_value())]
186
self.panel.unmap()
187
self.panel.monitor_index = int(self.monitor_number_adjustment.get_value())
188
Gtk4LayerShell.set_monitor(self.panel, monitor)
189
self.panel.show()
190
191
# Make the applets aware of the changed monitor
192
for area in (self.panel.left_area, self.panel.centre_area, self.panel.right_area):
193
applet = area.get_first_child()
194
while applet:
195
applet.output_changed()
196
applet = applet.get_next_sibling()
197
198
self.panel.get_application().save_config()
199
200
201
PANEL_POSITIONS_HUMAN = {
202
Gtk.PositionType.TOP: "top",
203
Gtk.PositionType.BOTTOM: "bottom",
204
Gtk.PositionType.LEFT: "left",
205
Gtk.PositionType.RIGHT: "right",
206
}
207
208
209
@Gtk.Template(filename=str(module_directory / "panel-manager.ui"))
210
class PanelManager(Gtk.Window):
211
__gtype_name__ = "PanelManager"
212
213
panel_editing_switch: Gtk.Switch = Gtk.Template.Child()
214
panel_stack: Gtk.Stack = Gtk.Template.Child()
215
current_panel: typing.Optional[Panel] = None
216
217
def __init__(self, application: Gtk.Application, **kwargs):
218
super().__init__(application=application, **kwargs)
219
220
self.connect("close-request", lambda *args: self.destroy())
221
222
action_group = Gio.SimpleActionGroup()
223
224
self.next_panel_action = Gio.SimpleAction(name="next-panel")
225
action_group.add_action(self.next_panel_action)
226
self.next_panel_action.connect("activate", lambda *args: self.panel_stack.set_visible_child(self.panel_stack.get_visible_child().get_next_sibling()))
227
228
self.previous_panel_action = Gio.SimpleAction(name="previous-panel")
229
action_group.add_action(self.previous_panel_action)
230
self.previous_panel_action.connect("activate", lambda *args: self.panel_stack.set_visible_child(self.panel_stack.get_visible_child().get_prev_sibling()))
231
232
self.insert_action_group("win", action_group)
233
if isinstance(self.get_application(), PanoramaPanel):
234
self.panel_editing_switch.set_active(application.edit_mode)
235
self.panel_editing_switch.connect("state-set", self.set_edit_mode)
236
237
self.connect("close-request", lambda *args: self.unflash_old_panel())
238
self.connect("close-request", lambda *args: self.destroy())
239
240
if isinstance(self.get_application(), PanoramaPanel):
241
app: PanoramaPanel = self.get_application()
242
for panel in app.panels:
243
configurator = PanelConfigurator(panel)
244
self.panel_stack.add_child(configurator)
245
configurator.monitor_number_adjustment.set_upper(len(app.monitors))
246
247
self.panel_stack.set_visible_child(self.panel_stack.get_first_child())
248
self.panel_stack.connect("notify::visible-child", self.set_visible_panel)
249
self.panel_stack.notify("visible-child")
250
251
def unflash_old_panel(self):
252
if self.current_panel:
253
for area in (self.current_panel.left_area, self.current_panel.centre_area, self.current_panel.right_area):
254
area.unflash()
255
256
def set_visible_panel(self, stack: Gtk.Stack, pspec: GObject.ParamSpec):
257
self.unflash_old_panel()
258
259
panel: Panel = stack.get_visible_child().panel
260
261
self.current_panel = panel
262
self.next_panel_action.set_enabled(stack.get_visible_child().get_next_sibling() is not None)
263
self.previous_panel_action.set_enabled(stack.get_visible_child().get_prev_sibling() is not None)
264
265
# Start an animation to show the user what panel is being edited
266
for area in (panel.left_area, panel.centre_area, panel.right_area):
267
area.flash()
268
269
def set_edit_mode(self, switch, value):
270
if isinstance(self.get_application(), PanoramaPanel):
271
self.get_application().set_edit_mode(value)
272
273
@Gtk.Template.Callback()
274
def save_settings(self, *args):
275
if isinstance(self.get_application(), PanoramaPanel):
276
self.get_application().save_config()
277
278
279
def get_applet_directories():
280
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
281
data_dirs = [Path(d) for d in os.getenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":")]
282
283
all_paths = [data_home / "panorama-panel" / "applets"] + [d / "panorama-panel" / "applets" for d in data_dirs]
284
return [d for d in all_paths if d.is_dir()]
285
286
287
def get_config_file():
288
config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
289
290
return config_home / "panorama-panel" / "config.yaml"
291
292
293
class AppletArea(Gtk.Box):
294
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL):
295
super().__init__()
296
297
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
298
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
299
self.drop_target.connect("drop", self.drop_applet)
300
301
def drop_applet(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
302
applet = self.get_root().get_application().drags[value]
303
old_area: AppletArea = applet.get_parent()
304
old_area.remove(applet)
305
# Find the position where to insert the applet
306
child = self.get_first_child()
307
while child:
308
allocation = child.get_allocation()
309
child_x, child_y = self.translate_coordinates(self, 0, 0)
310
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
311
midpoint = child_x + allocation.width / 2
312
if x < midpoint:
313
applet.insert_before(self, child)
314
break
315
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
316
midpoint = child_y + allocation.height / 2
317
if y < midpoint:
318
applet.insert_before(self, child)
319
break
320
child = child.get_next_sibling()
321
else:
322
self.append(applet)
323
applet.show()
324
325
self.get_root().get_application().drags.pop(value)
326
self.get_root().get_application().save_config()
327
328
return True
329
330
def set_edit_mode(self, value):
331
panel: Panel = self.get_root()
332
child = self.get_first_child()
333
while child is not None:
334
if value:
335
child.make_draggable()
336
else:
337
child.restore_drag()
338
child.set_opacity(0.75 if value else 1)
339
child = child.get_next_sibling()
340
341
if value:
342
self.add_controller(self.drop_target)
343
if panel.get_orientation() == Gtk.Orientation.HORIZONTAL:
344
self.set_size_request(48, 0)
345
elif panel.get_orientation() == Gtk.Orientation.VERTICAL:
346
self.set_size_request(0, 48)
347
else:
348
self.remove_controller(self.drop_target)
349
self.set_size_request(0, 0)
350
351
def flash(self):
352
self.add_css_class("panel-flash")
353
354
def unflash(self):
355
self.remove_css_class("panel-flash")
356
357
358
POSITION_TO_LAYER_SHELL_EDGE = {
359
Gtk.PositionType.TOP: Gtk4LayerShell.Edge.TOP,
360
Gtk.PositionType.BOTTOM: Gtk4LayerShell.Edge.BOTTOM,
361
Gtk.PositionType.LEFT: Gtk4LayerShell.Edge.LEFT,
362
Gtk.PositionType.RIGHT: Gtk4LayerShell.Edge.RIGHT,
363
}
364
365
366
class Panel(Gtk.Window):
367
def __init__(self, application: Gtk.Application, monitor: Gdk.Monitor, position: Gtk.PositionType = Gtk.PositionType.TOP, size: int = 40, autohide: bool = False, hide_time: int = 0, can_capture_keyboard: bool = False):
368
super().__init__(application=application)
369
self.drop_motion_controller = None
370
self.motion_controller = None
371
self.set_decorated(False)
372
self.position = None
373
self.autohide = None
374
self.monitor_name = monitor.get_connector()
375
self.hide_time = None
376
self.monitor_index = 0
377
self.can_capture_keyboard = can_capture_keyboard
378
self.open_popovers: set[int] = set()
379
380
self.add_css_class("panorama-panel")
381
382
Gtk4LayerShell.init_for_window(self)
383
Gtk4LayerShell.set_namespace(self, "com.roundabout_host.panorama.panel")
384
Gtk4LayerShell.set_monitor(self, monitor)
385
386
Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP)
387
if can_capture_keyboard:
388
Gtk4LayerShell.set_keyboard_mode(self, Gtk4LayerShell.KeyboardMode.ON_DEMAND)
389
else:
390
Gtk4LayerShell.set_keyboard_mode(self, Gtk4LayerShell.KeyboardMode.NONE)
391
392
box = Gtk.CenterBox()
393
394
self.left_area = AppletArea(orientation=box.get_orientation())
395
self.centre_area = AppletArea(orientation=box.get_orientation())
396
self.right_area = AppletArea(orientation=box.get_orientation())
397
398
box.set_start_widget(self.left_area)
399
box.set_center_widget(self.centre_area)
400
box.set_end_widget(self.right_area)
401
402
self.set_child(box)
403
self.set_position(position)
404
self.set_size(size)
405
self.set_autohide(autohide, hide_time)
406
407
# Add a context menu
408
menu = Gio.Menu()
409
410
menu.append(_("Open _manager"), "panel.manager")
411
412
self.context_menu = Gtk.PopoverMenu.new_from_model(menu)
413
self.context_menu.set_has_arrow(False)
414
self.context_menu.set_parent(self)
415
self.context_menu.set_halign(Gtk.Align.START)
416
self.context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
417
panorama_panel.track_popover(self.context_menu)
418
419
right_click_controller = Gtk.GestureClick()
420
right_click_controller.set_button(3)
421
right_click_controller.connect("pressed", self.show_context_menu)
422
423
self.add_controller(right_click_controller)
424
425
action_group = Gio.SimpleActionGroup()
426
manager_action = Gio.SimpleAction.new("manager", None)
427
manager_action.connect("activate", self.show_manager)
428
action_group.add_action(manager_action)
429
self.insert_action_group("panel", action_group)
430
431
def reset_margins(self):
432
for edge in POSITION_TO_LAYER_SHELL_EDGE.values():
433
Gtk4LayerShell.set_margin(self, edge, 0)
434
435
def set_edit_mode(self, value):
436
for area in (self.left_area, self.centre_area, self.right_area):
437
area.set_edit_mode(value)
438
439
def show_context_menu(self, gesture, n_presses, x, y):
440
rect = Gdk.Rectangle()
441
rect.x = int(x)
442
rect.y = int(y)
443
rect.width = 1
444
rect.height = 1
445
446
self.context_menu.set_pointing_to(rect)
447
self.context_menu.popup()
448
449
def slide_in(self):
450
if not self.autohide:
451
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 0)
452
return False
453
454
if Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) >= 0:
455
return False
456
457
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) + 1)
458
return True
459
460
def slide_out(self):
461
if not self.autohide:
462
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 0)
463
return False
464
465
if Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) <= 1 - self.size:
466
return False
467
468
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) - 1)
469
return True
470
471
def show_manager(self, _0=None, _1=None):
472
if self.get_application():
473
if not self.get_application().manager_window:
474
self.get_application().manager_window = PanelManager(self.get_application())
475
self.get_application().manager_window.connect("close-request", self.get_application().reset_manager_window)
476
self.get_application().manager_window.present()
477
478
def get_orientation(self):
479
box = self.get_first_child()
480
return box.get_orientation()
481
482
def set_size(self, value: int):
483
self.size = int(value)
484
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
485
self.set_size_request(800, self.size)
486
self.set_default_size(800, self.size)
487
else:
488
self.set_size_request(self.size, 600)
489
self.set_default_size(self.size, 600)
490
491
def set_position(self, position: Gtk.PositionType):
492
self.position = position
493
self.reset_margins()
494
match self.position:
495
case Gtk.PositionType.TOP:
496
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, False)
497
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
498
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
499
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True)
500
case Gtk.PositionType.BOTTOM:
501
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, False)
502
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
503
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
504
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True)
505
case Gtk.PositionType.LEFT:
506
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, False)
507
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
508
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
509
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
510
case Gtk.PositionType.RIGHT:
511
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, False)
512
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True)
513
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
514
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
515
box = self.get_first_child()
516
match self.position:
517
case Gtk.PositionType.TOP | Gtk.PositionType.BOTTOM:
518
box.set_orientation(Gtk.Orientation.HORIZONTAL)
519
for area in (self.left_area, self.centre_area, self.right_area):
520
area.set_orientation(Gtk.Orientation.HORIZONTAL)
521
case Gtk.PositionType.LEFT | Gtk.PositionType.RIGHT:
522
box.set_orientation(Gtk.Orientation.VERTICAL)
523
for area in (self.left_area, self.centre_area, self.right_area):
524
area.set_orientation(Gtk.Orientation.VERTICAL)
525
526
if self.autohide:
527
if not self.open_popovers:
528
GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_out)
529
530
def set_autohide(self, autohide: bool, hide_time: int):
531
self.autohide = autohide
532
self.hide_time = hide_time
533
if not self.autohide:
534
self.reset_margins()
535
Gtk4LayerShell.auto_exclusive_zone_enable(self)
536
if self.motion_controller is not None:
537
self.remove_controller(self.motion_controller)
538
if self.drop_motion_controller is not None:
539
self.remove_controller(self.drop_motion_controller)
540
self.motion_controller = None
541
self.drop_motion_controller = None
542
self.reset_margins()
543
else:
544
Gtk4LayerShell.set_exclusive_zone(self, 0)
545
# Only leave 1px of the window as a "mouse sensor"
546
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 1 - self.size)
547
self.motion_controller = Gtk.EventControllerMotion()
548
self.add_controller(self.motion_controller)
549
self.drop_motion_controller = Gtk.DropControllerMotion()
550
self.add_controller(self.drop_motion_controller)
551
552
self.motion_controller.connect("enter", lambda *args: GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_in))
553
self.drop_motion_controller.connect("enter", lambda *args: GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_in))
554
self.motion_controller.connect("leave", lambda *args: not self.open_popovers and GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_out))
555
self.drop_motion_controller.connect("leave", lambda *args: not self.open_popovers and GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_out))
556
557
def wl_output_enter(self, output, name):
558
for area in (self.left_area, self.centre_area, self.right_area):
559
applet = area.get_first_child()
560
while applet:
561
applet.wl_output_enter(output, name)
562
applet = applet.get_next_sibling()
563
564
def foreign_toplevel_new(self, manager, toplevel):
565
for area in (self.left_area, self.centre_area, self.right_area):
566
applet = area.get_first_child()
567
while applet:
568
applet.foreign_toplevel_new(manager, toplevel)
569
applet = applet.get_next_sibling()
570
571
def foreign_toplevel_output_enter(self, toplevel, output):
572
for area in (self.left_area, self.centre_area, self.right_area):
573
applet = area.get_first_child()
574
while applet:
575
applet.foreign_toplevel_output_enter(toplevel, output)
576
applet = applet.get_next_sibling()
577
578
def foreign_toplevel_output_leave(self, toplevel, output):
579
for area in (self.left_area, self.centre_area, self.right_area):
580
applet = area.get_first_child()
581
while applet:
582
applet.foreign_toplevel_output_leave(toplevel, output)
583
applet = applet.get_next_sibling()
584
585
def foreign_toplevel_app_id(self, toplevel, app_id):
586
for area in (self.left_area, self.centre_area, self.right_area):
587
applet = area.get_first_child()
588
while applet:
589
applet.foreign_toplevel_app_id(toplevel, app_id)
590
applet = applet.get_next_sibling()
591
592
def foreign_toplevel_title(self, toplevel, title):
593
for area in (self.left_area, self.centre_area, self.right_area):
594
applet = area.get_first_child()
595
while applet:
596
applet.foreign_toplevel_title(toplevel, title)
597
applet = applet.get_next_sibling()
598
599
def foreign_toplevel_state(self, toplevel, state):
600
for area in (self.left_area, self.centre_area, self.right_area):
601
applet = area.get_first_child()
602
while applet:
603
applet.foreign_toplevel_state(toplevel, state)
604
applet = applet.get_next_sibling()
605
606
def foreign_toplevel_closed(self, toplevel):
607
for area in (self.left_area, self.centre_area, self.right_area):
608
applet = area.get_first_child()
609
while applet:
610
applet.foreign_toplevel_closed(toplevel)
611
applet = applet.get_next_sibling()
612
613
def foreign_toplevel_refresh(self):
614
for area in (self.left_area, self.centre_area, self.right_area):
615
applet = area.get_first_child()
616
while applet:
617
applet.foreign_toplevel_refresh()
618
applet = applet.get_next_sibling()
619
620
621
def get_all_subclasses(klass: type) -> list[type]:
622
subclasses = []
623
for subclass in klass.__subclasses__():
624
subclasses.append(subclass)
625
subclasses += get_all_subclasses(subclass)
626
627
return subclasses
628
629
def load_packages_from_dir(dir_path: Path):
630
loaded_modules = []
631
632
for path in dir_path.iterdir():
633
if path.name.startswith("_"):
634
continue
635
636
if path.is_dir() and (path / "__init__.py").exists():
637
try:
638
module_name = path.name
639
spec = importlib.util.spec_from_file_location(module_name, path / "__init__.py")
640
module = importlib.util.module_from_spec(spec)
641
module.__package__ = module_name
642
sys.modules[module_name] = module
643
spec.loader.exec_module(module)
644
loaded_modules.append(module)
645
except Exception as e:
646
print(f"Failed to load applet module {path.name}: {e}", file=sys.stderr)
647
traceback.print_exc()
648
else:
649
continue
650
651
return loaded_modules
652
653
654
PANEL_POSITIONS = {
655
"top": Gtk.PositionType.TOP,
656
"bottom": Gtk.PositionType.BOTTOM,
657
"left": Gtk.PositionType.LEFT,
658
"right": Gtk.PositionType.RIGHT,
659
}
660
661
662
PANEL_POSITIONS_REVERSE = {
663
Gtk.PositionType.TOP: "top",
664
Gtk.PositionType.BOTTOM: "bottom",
665
Gtk.PositionType.LEFT: "left",
666
Gtk.PositionType.RIGHT: "right",
667
}
668
669
670
class PanoramaPanel(Gtk.Application):
671
def __init__(self):
672
super().__init__(
673
application_id="com.roundabout_host.roundabout.PanoramaPanel",
674
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE
675
)
676
self.display = Gdk.Display.get_default()
677
self.monitors = self.display.get_monitors()
678
self.applets_by_name: dict[str, panorama_panel.Applet] = {}
679
self.panels: list[Panel] = []
680
self.wl_globals = []
681
self.wl_display = None
682
self.wl_seat = None
683
self.wl_shm = None
684
self.foreign_toplevel_manager = None
685
self.screencopy_manager = None
686
self.manager_window = None
687
self.edit_mode = False
688
self.output_proxies = []
689
self.drags = {}
690
self.wl_output_ids = set()
691
self.started = False
692
self.all_handles: set[ZwlrForeignToplevelHandleV1] = set()
693
self.obsolete_handles: set[ZwlrForeignToplevelHandleV1] = set()
694
695
self.add_main_option(
696
"trigger",
697
ord("t"),
698
GLib.OptionFlags.NONE,
699
GLib.OptionArg.STRING,
700
_("Trigger an action which can be processed by the applets"),
701
_("NAME")
702
)
703
704
def destroy_foreign_toplevel_manager(self):
705
self.foreign_toplevel_manager.destroy()
706
self.wl_globals.remove(self.foreign_toplevel_manager)
707
self.foreign_toplevel_manager = None
708
self.foreign_toplevel_refresh()
709
710
def on_foreign_toplevel_manager_finish(self, manager: ZwlrForeignToplevelManagerV1):
711
if self.foreign_toplevel_manager:
712
self.destroy_foreign_toplevel_manager()
713
714
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
715
handle: ZwlrForeignToplevelHandleV1):
716
self.all_handles.add(handle)
717
718
for panel in self.panels:
719
panel.foreign_toplevel_new(manager, handle)
720
handle.dispatcher["title"] = self.on_title_changed
721
handle.dispatcher["app_id"] = self.on_app_id_changed
722
handle.dispatcher["output_enter"] = self.on_output_enter
723
handle.dispatcher["output_leave"] = self.on_output_leave
724
handle.dispatcher["state"] = self.on_state_changed
725
handle.dispatcher["closed"] = self.on_closed
726
727
def on_output_enter(self, handle, output):
728
if handle in self.obsolete_handles:
729
return
730
for panel in self.panels:
731
panel.foreign_toplevel_output_enter(handle, output)
732
733
def on_output_leave(self, handle, output):
734
if handle in self.obsolete_handles:
735
return
736
for panel in self.panels:
737
panel.foreign_toplevel_output_leave(handle, output)
738
739
def on_title_changed(self, handle, title):
740
if handle in self.obsolete_handles:
741
return
742
for panel in self.panels:
743
panel.foreign_toplevel_title(handle, title)
744
745
def on_app_id_changed(self, handle, app_id):
746
if handle in self.obsolete_handles:
747
return
748
for panel in self.panels:
749
panel.foreign_toplevel_app_id(handle, app_id)
750
751
def on_state_changed(self, handle, state):
752
if handle in self.obsolete_handles:
753
return
754
for panel in self.panels:
755
panel.foreign_toplevel_state(handle, state)
756
757
def on_closed(self, handle):
758
if handle in self.obsolete_handles:
759
return
760
for panel in self.panels:
761
panel.foreign_toplevel_closed(handle)
762
763
def foreign_toplevel_refresh(self):
764
self.obsolete_handles = self.all_handles.copy()
765
766
for panel in self.panels:
767
panel.foreign_toplevel_refresh()
768
769
def receive_output_name(self, output: WlOutput, name: str):
770
output.name = name
771
772
def idle_proxy_cover(self, output_name):
773
for monitor in self.display.get_monitors():
774
wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
775
found = False
776
for proxy in self.output_proxies:
777
if proxy._ptr == wl_output:
778
found = True
779
if wl_output and not found:
780
print("Create proxy")
781
output_proxy = WlOutputProxy(wl_output, self.wl_display)
782
output_proxy.interface.registry[output_proxy._ptr] = output_proxy
783
self.output_proxies.append(output_proxy)
784
785
def on_global(self, registry, name, interface, version):
786
if interface == "zwlr_foreign_toplevel_manager_v1":
787
self.foreign_toplevel_manager_id = name
788
self.foreign_toplevel_version = version
789
elif interface == "wl_output":
790
output = registry.bind(name, WlOutput, version)
791
output.id = name
792
self.wl_globals.append(output)
793
print(f"global {name} {interface} {version}")
794
output.global_name = name
795
output.name = None
796
output.dispatcher["name"] = self.receive_output_name
797
while not output.name or not self.foreign_toplevel_version:
798
self.wl_display.dispatch(block=True)
799
print(output.name)
800
self.wl_output_ids.add(output.global_name)
801
if not output.name.startswith("live-preview"):
802
self.generate_panels(output.name)
803
for panel in self.panels:
804
panel.wl_output_enter(output, output.name)
805
if not output.name.startswith("live-preview"):
806
if self.foreign_toplevel_manager:
807
self.foreign_toplevel_manager.stop()
808
self.wl_display.roundtrip()
809
if self.foreign_toplevel_manager:
810
self.destroy_foreign_toplevel_manager()
811
self.foreign_toplevel_manager = self.registry.bind(self.foreign_toplevel_manager_id, ZwlrForeignToplevelManagerV1, self.foreign_toplevel_version)
812
self.foreign_toplevel_manager.id = self.foreign_toplevel_manager_id
813
self.wl_globals.append(self.foreign_toplevel_manager)
814
self.foreign_toplevel_manager.dispatcher["toplevel"] = self.on_new_toplevel
815
self.foreign_toplevel_manager.dispatcher["finished"] = self.on_foreign_toplevel_manager_finish
816
self.wl_display.roundtrip()
817
print(f"Monitor {output.name} ({interface}) connected", file=sys.stderr)
818
elif interface == "wl_seat":
819
self.wl_seat = registry.bind(name, WlSeat, version)
820
self.wl_seat.id = name
821
self.wl_globals.append(self.wl_seat)
822
elif interface == "wl_shm":
823
self.wl_shm = registry.bind(name, WlShm, version)
824
self.wl_shm.id = name
825
self.wl_globals.append(self.wl_shm)
826
elif interface == "zwlr_screencopy_manager_v1":
827
self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version)
828
self.screencopy_manager.id = name
829
self.wl_globals.append(self.screencopy_manager)
830
831
def on_global_remove(self, registry, name):
832
if name in self.wl_output_ids:
833
print(f"Monitor {name} disconnected", file=sys.stderr)
834
self.wl_output_ids.discard(name)
835
for g in self.wl_globals:
836
if g.id == name:
837
g.destroy()
838
self.wl_globals.remove(g)
839
break
840
841
def generate_panels(self, output_name):
842
print("Generating panels...", file=sys.stderr)
843
self.exit_all_applets()
844
845
for i, monitor in enumerate(self.monitors):
846
geometry = monitor.get_geometry()
847
print(f"Monitor {i}: {geometry.width}x{geometry.height} at {geometry.x},{geometry.y}")
848
849
for panel in self.panels:
850
if panel.monitor_name == output_name:
851
panel.destroy()
852
with open(get_config_file(), "r") as config_file:
853
yaml_loader = yaml.YAML(typ="rt")
854
yaml_file = yaml_loader.load(config_file)
855
for panel_data in yaml_file["panels"]:
856
if panel_data["monitor"] != output_name:
857
continue
858
position = PANEL_POSITIONS[panel_data["position"]]
859
my_monitor = None
860
for monitor in self.display.get_monitors():
861
if monitor.get_connector() == panel_data["monitor"]:
862
my_monitor = monitor
863
break
864
if not my_monitor:
865
continue
866
GLib.idle_add(self.idle_proxy_cover, my_monitor.get_connector())
867
size = panel_data["size"]
868
autohide = panel_data["autohide"]
869
hide_time = panel_data["hide_time"]
870
can_capture_keyboard = panel_data["can_capture_keyboard"]
871
872
panel = Panel(self, my_monitor, position, size, autohide, hide_time, can_capture_keyboard)
873
self.panels.append(panel)
874
875
print(f"{size}px panel on {position}, autohide is {autohide} ({hide_time}ms)")
876
877
for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)):
878
applet_list = panel_data["applets"].get(area_name)
879
if applet_list is None:
880
continue
881
882
for applet in applet_list:
883
item = list(applet.items())[0]
884
AppletClass = self.applets_by_name[item[0]]
885
options = item[1]
886
applet_widget = AppletClass(orientation=panel.get_orientation(), config=options)
887
applet_widget.connect("config-changed", self.save_config)
888
applet_widget.set_panel_position(panel.position)
889
890
area.append(applet_widget)
891
892
panel.present()
893
panel.realize()
894
panel.monitor_name = my_monitor.get_connector()
895
896
def do_startup(self):
897
Gtk.Application.do_startup(self)
898
899
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
900
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
901
902
all_applets = list(
903
chain.from_iterable(load_packages_from_dir(d) for d in get_applet_directories()))
904
print("Applets:")
905
subclasses = get_all_subclasses(panorama_panel.Applet)
906
for subclass in subclasses:
907
if subclass.__name__ in self.applets_by_name:
908
print(f"Name conflict for applet {subclass.__name__}. Only one will be loaded.",
909
file=sys.stderr)
910
self.applets_by_name[subclass.__name__] = subclass
911
912
self.wl_display = Display()
913
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
914
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None)))
915
self.wl_display._ptr = wl_display_ptr
916
917
self.foreign_toplevel_manager_id = None
918
self.foreign_toplevel_version = None
919
920
self.idle_proxy_cover(None)
921
self.registry = self.wl_display.get_registry()
922
self.registry.dispatcher["global"] = self.on_global
923
self.registry.dispatcher["global_remove"] = self.on_global_remove
924
self.wl_display.roundtrip()
925
if self.foreign_toplevel_version:
926
print("Foreign Toplevel Interface: Found")
927
else:
928
print("Foreign Toplevel Interface: Not Found")
929
930
self.hold()
931
932
def do_activate(self):
933
Gio.Application.do_activate(self)
934
935
def save_config(self, *args):
936
print("Saving configuration file")
937
with open(get_config_file(), "w") as config_file:
938
yaml_writer = yaml.YAML(typ="rt")
939
data = {"panels": []}
940
for panel in self.panels:
941
panel_data = {
942
"position": PANEL_POSITIONS_REVERSE[panel.position],
943
"monitor": panel.monitor_name,
944
"size": panel.size,
945
"autohide": panel.autohide,
946
"hide_time": panel.hide_time,
947
"can_capture_keyboard": panel.can_capture_keyboard,
948
"applets": {}
949
}
950
951
for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)):
952
panel_data["applets"][area_name] = []
953
applet = area.get_first_child()
954
while applet is not None:
955
panel_data["applets"][area_name].append({
956
applet.__class__.__name__: applet.get_config(),
957
})
958
959
applet = applet.get_next_sibling()
960
961
data["panels"].append(panel_data)
962
963
yaml_writer.dump(data, config_file)
964
965
def exit_all_applets(self):
966
for panel in self.panels:
967
for area in (panel.left_area, panel.centre_area, panel.right_area):
968
applet = area.get_first_child()
969
while applet is not None:
970
applet.shutdown(self)
971
972
applet = applet.get_next_sibling()
973
974
def do_shutdown(self):
975
print("Shutting down")
976
self.exit_all_applets()
977
Gtk.Application.do_shutdown(self)
978
979
def do_command_line(self, command_line: Gio.ApplicationCommandLine):
980
options = command_line.get_options_dict()
981
args = command_line.get_arguments()[1:]
982
983
trigger_variant = options.lookup_value("trigger", GLib.VariantType("s"))
984
if trigger_variant:
985
action_name = trigger_variant.get_string()
986
print(app.list_actions())
987
self.activate_action(action_name, None)
988
print(f"Triggered {action_name}")
989
990
return 0
991
992
def set_edit_mode(self, value):
993
self.edit_mode = value
994
for panel in self.panels:
995
panel.set_edit_mode(value)
996
997
def reset_manager_window(self, *args):
998
self.manager_window = None
999
1000
1001
if __name__ == "__main__":
1002
app = PanoramaPanel()
1003
app.run(sys.argv)
1004