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