By using this site, you agree to have cookies stored on your device, strictly for functional purposes, such as storing your session and preferences.

Dismiss

 main.py

View raw Download
text/plain • 39.56 kiB
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 foreign_toplevel_refresh(self):
619
for area in (self.left_area, self.centre_area, self.right_area):
620
applet = area.get_first_child()
621
while applet:
622
applet.foreign_toplevel_refresh()
623
applet = applet.get_next_sibling()
624
625
626
def get_all_subclasses(klass: type) -> list[type]:
627
subclasses = []
628
for subclass in klass.__subclasses__():
629
subclasses.append(subclass)
630
subclasses += get_all_subclasses(subclass)
631
632
return subclasses
633
634
def load_packages_from_dir(dir_path: Path):
635
loaded_modules = []
636
637
for path in dir_path.iterdir():
638
if path.name.startswith("_"):
639
continue
640
641
if path.is_dir() and (path / "__init__.py").exists():
642
try:
643
module_name = path.name
644
spec = importlib.util.spec_from_file_location(module_name, path / "__init__.py")
645
module = importlib.util.module_from_spec(spec)
646
spec.loader.exec_module(module)
647
loaded_modules.append(module)
648
except Exception as e:
649
print(f"Failed to load applet module {path.name}: {e}", file=sys.stderr)
650
else:
651
continue
652
653
return loaded_modules
654
655
656
PANEL_POSITIONS = {
657
"top": Gtk.PositionType.TOP,
658
"bottom": Gtk.PositionType.BOTTOM,
659
"left": Gtk.PositionType.LEFT,
660
"right": Gtk.PositionType.RIGHT,
661
}
662
663
664
PANEL_POSITIONS_REVERSE = {
665
Gtk.PositionType.TOP: "top",
666
Gtk.PositionType.BOTTOM: "bottom",
667
Gtk.PositionType.LEFT: "left",
668
Gtk.PositionType.RIGHT: "right",
669
}
670
671
672
class PanoramaPanel(Gtk.Application):
673
def __init__(self):
674
super().__init__(
675
application_id="com.roundabout_host.roundabout.PanoramaPanel",
676
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE
677
)
678
self.display = Gdk.Display.get_default()
679
self.monitors = self.display.get_monitors()
680
for monitor in self.monitors:
681
monitor.output_proxy = None
682
self.applets_by_name: dict[str, panorama_panel.Applet] = {}
683
self.panels: list[Panel] = []
684
self.wl_globals = []
685
self.wl_display = None
686
self.compositor = None
687
self.seat = None
688
self.output = None
689
self.wl_shm = None
690
self.manager = None
691
self.screencopy_manager = None
692
self.manager_window = None
693
self.edit_mode = False
694
self.output_proxies = []
695
self.drags = {}
696
self.wl_output_ids = set()
697
self.started = False
698
699
self.add_main_option(
700
"trigger",
701
ord("t"),
702
GLib.OptionFlags.NONE,
703
GLib.OptionArg.STRING,
704
_("Trigger an action which can be processed by the applets"),
705
_("NAME")
706
)
707
708
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
709
handle: ZwlrForeignToplevelHandleV1):
710
print("on_new_toplevel")
711
for panel in self.panels:
712
panel.foreign_toplevel_new(manager, handle)
713
handle.dispatcher["title"] = self.on_title_changed
714
handle.dispatcher["app_id"] = self.on_app_id_changed
715
handle.dispatcher["output_enter"] = self.on_output_enter
716
handle.dispatcher["output_leave"] = self.on_output_leave
717
handle.dispatcher["state"] = self.on_state_changed
718
handle.dispatcher["closed"] = self.on_closed
719
720
def on_output_leave(self, handle, output):
721
for panel in self.panels:
722
panel.foreign_toplevel_output_leave(handle, output)
723
724
def on_output_enter(self, handle, output):
725
for panel in self.panels:
726
panel.foreign_toplevel_output_enter(handle, output)
727
728
def on_title_changed(self, handle, title):
729
for panel in self.panels:
730
panel.foreign_toplevel_title(handle, title)
731
732
def on_app_id_changed(self, handle, app_id):
733
for panel in self.panels:
734
panel.foreign_toplevel_app_id(handle, app_id)
735
736
def on_state_changed(self, handle, state):
737
for panel in self.panels:
738
panel.foreign_toplevel_state(handle, state)
739
740
def on_closed(self, handle):
741
for panel in self.panels:
742
panel.foreign_toplevel_closed(handle)
743
744
def receive_output_name(self, output: WlOutput, name: str):
745
output.name = name
746
747
def idle_proxy_cover(self, output_name):
748
for monitor in self.display.get_monitors():
749
wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
750
found = False
751
for proxy in self.output_proxies:
752
if proxy._ptr == wl_output:
753
found = True
754
if wl_output and not found:
755
print("Create proxy")
756
output_proxy = WlOutputProxy(wl_output, self.wl_display)
757
output_proxy.interface.registry[output_proxy._ptr] = output_proxy
758
self.output_proxies.append(output_proxy)
759
760
def on_global(self, registry, name, interface, version):
761
if interface == "zwlr_foreign_toplevel_manager_v1":
762
print("Interface registered")
763
self.foreign_toplevel_manager_id = name
764
self.foreign_toplevel_version = version
765
elif interface == "wl_output":
766
output = registry.bind(name, WlOutput, version)
767
output.id = name
768
self.wl_globals.append(output)
769
print(f"global {name} {interface} {version}")
770
output.global_name = name
771
output.name = None
772
output.dispatcher["name"] = self.receive_output_name
773
while not output.name or not self.foreign_toplevel_version:
774
self.wl_display.dispatch(block=True)
775
print(output.name)
776
self.wl_output_ids.add(output.global_name)
777
if not output.name.startswith("live-preview"):
778
self.generate_panels(output.name)
779
for panel in self.panels:
780
panel.wl_output_enter(output, output.name)
781
if not output.name.startswith("live-preview"):
782
if self.manager:
783
self.manager.destroy()
784
for panel in self.panels:
785
panel.foreign_toplevel_refresh()
786
self.wl_globals.remove(self.manager)
787
self.manager = self.registry.bind(self.foreign_toplevel_manager_id, ZwlrForeignToplevelManagerV1, self.foreign_toplevel_version)
788
self.manager.id = self.foreign_toplevel_manager_id
789
self.wl_globals.append(self.manager)
790
self.manager.dispatcher["toplevel"] = self.on_new_toplevel
791
self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
792
self.wl_display.roundtrip()
793
self.panels_generated = True
794
print(f"Monitor {name} ({interface}) connected", file=sys.stderr)
795
elif interface == "wl_seat":
796
print("Seat found")
797
self.seat = registry.bind(name, WlSeat, version)
798
self.seat.id = name
799
self.wl_globals.append(self.seat)
800
elif interface == "wl_shm":
801
self.wl_shm = registry.bind(name, WlShm, version)
802
self.wl_shm.id = name
803
self.wl_globals.append(self.wl_shm)
804
elif interface == "wl_compositor":
805
self.compositor = registry.bind(name, WlCompositor, version)
806
self.compositor.id = name
807
self.wl_globals.append(self.compositor)
808
elif interface == "zwlr_screencopy_manager_v1":
809
self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version)
810
self.screencopy_manager.id = name
811
self.wl_globals.append(self.screencopy_manager)
812
813
def on_global_remove(self, registry, name):
814
if name in self.wl_output_ids:
815
print(f"Monitor {name} disconnected", file=sys.stderr)
816
#self.generate_panels()
817
#for g in self.wl_globals:
818
# if g.id == name:
819
# for panel in self.panels:
820
# panel.wl_output_leave(g, g.name)
821
# break
822
self.wl_output_ids.discard(name)
823
for g in self.wl_globals:
824
if g.id == name:
825
g.destroy()
826
self.wl_globals.remove(g)
827
break
828
829
def generate_panels(self, output_name):
830
print("Generating panels...", file=sys.stderr)
831
self.exit_all_applets()
832
833
for i, monitor in enumerate(self.monitors):
834
geometry = monitor.get_geometry()
835
print(f"Monitor {i}: {geometry.width}x{geometry.height} at {geometry.x},{geometry.y}")
836
837
for panel in self.panels:
838
if panel.monitor_name == output_name:
839
panel.destroy()
840
with open(get_config_file(), "r") as config_file:
841
yaml_loader = yaml.YAML(typ="rt")
842
yaml_file = yaml_loader.load(config_file)
843
for panel_data in yaml_file["panels"]:
844
print(f"comparing {panel_data["monitor"]} =? {output_name}")
845
if panel_data["monitor"] != output_name:
846
continue
847
position = PANEL_POSITIONS[panel_data["position"]]
848
my_monitor = None
849
for monitor in self.display.get_monitors():
850
if monitor.get_connector() == panel_data["monitor"]:
851
my_monitor = monitor
852
break
853
if not my_monitor:
854
continue
855
GLib.idle_add(self.idle_proxy_cover, my_monitor.get_connector())
856
size = panel_data["size"]
857
autohide = panel_data["autohide"]
858
hide_time = panel_data["hide_time"]
859
can_capture_keyboard = panel_data["can_capture_keyboard"]
860
861
panel = Panel(self, my_monitor, position, size, autohide, hide_time, can_capture_keyboard)
862
self.panels.append(panel)
863
864
print(f"{size}px panel on {position}, autohide is {autohide} ({hide_time}ms)")
865
866
for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)):
867
applet_list = panel_data["applets"].get(area_name)
868
if applet_list is None:
869
continue
870
871
for applet in applet_list:
872
item = list(applet.items())[0]
873
AppletClass = self.applets_by_name[item[0]]
874
options = item[1]
875
applet_widget = AppletClass(orientation=panel.get_orientation(), config=options)
876
applet_widget.connect("config-changed", self.save_config)
877
applet_widget.set_panel_position(panel.position)
878
879
area.append(applet_widget)
880
881
panel.present()
882
panel.realize()
883
panel.monitor_name = my_monitor.get_connector()
884
885
def prepare(self):
886
self.release()
887
888
print("started", file=sys.stderr)
889
890
def do_startup(self):
891
Gtk.Application.do_startup(self)
892
893
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
894
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
895
896
all_applets = list(
897
chain.from_iterable(load_packages_from_dir(d) for d in get_applet_directories()))
898
print("Applets:")
899
subclasses = get_all_subclasses(panorama_panel.Applet)
900
for subclass in subclasses:
901
if subclass.__name__ in self.applets_by_name:
902
print(f"Name conflict for applet {subclass.__name__}. Only one will be loaded.",
903
file=sys.stderr)
904
self.applets_by_name[subclass.__name__] = subclass
905
906
self.wl_display = Display()
907
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
908
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None)))
909
self.wl_display._ptr = wl_display_ptr
910
911
# Intentionally commented: the display is already connected by GTK
912
# self.display.connect() # Iterate through monitors and get their Wayland output (wl_output)
913
914
self.panels_generated = False
915
self.got_output_name = False
916
self.foreign_toplevel_manager_id = None
917
self.foreign_toplevel_version = None
918
919
self.idle_proxy_cover(None)
920
self.registry = self.wl_display.get_registry()
921
self.registry.dispatcher["global"] = self.on_global
922
self.registry.dispatcher["global_remove"] = self.on_global_remove
923
self.wl_display.roundtrip()
924
925
self.hold()
926
927
def do_activate(self):
928
Gio.Application.do_activate(self)
929
930
def save_config(self, *args):
931
print("Saving configuration file")
932
with open(get_config_file(), "w") as config_file:
933
yaml_writer = yaml.YAML(typ="rt")
934
data = {"panels": []}
935
for panel in self.panels:
936
panel_data = {
937
"position": PANEL_POSITIONS_REVERSE[panel.position],
938
"monitor": panel.monitor_index,
939
"size": panel.size,
940
"autohide": panel.autohide,
941
"hide_time": panel.hide_time,
942
"can_capture_keyboard": panel.can_capture_keyboard,
943
"applets": {}
944
}
945
946
for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)):
947
panel_data["applets"][area_name] = []
948
applet = area.get_first_child()
949
while applet is not None:
950
panel_data["applets"][area_name].append({
951
applet.__class__.__name__: applet.get_config(),
952
})
953
954
applet = applet.get_next_sibling()
955
956
data["panels"].append(panel_data)
957
958
yaml_writer.dump(data, config_file)
959
960
def exit_all_applets(self):
961
for panel in self.panels:
962
for area in (panel.left_area, panel.centre_area, panel.right_area):
963
applet = area.get_first_child()
964
while applet is not None:
965
applet.shutdown(self)
966
967
applet = applet.get_next_sibling()
968
969
def do_shutdown(self):
970
print("Shutting down")
971
self.exit_all_applets()
972
Gtk.Application.do_shutdown(self)
973
974
def do_command_line(self, command_line: Gio.ApplicationCommandLine):
975
options = command_line.get_options_dict()
976
args = command_line.get_arguments()[1:]
977
978
trigger_variant = options.lookup_value("trigger", GLib.VariantType("s"))
979
if trigger_variant:
980
action_name = trigger_variant.get_string()
981
print(app.list_actions())
982
self.activate_action(action_name, None)
983
print(f"Triggered {action_name}")
984
985
return 0
986
987
def set_edit_mode(self, value):
988
self.edit_mode = value
989
for panel in self.panels:
990
panel.set_edit_mode(value)
991
992
def reset_manager_window(self, *args):
993
self.manager_window = None
994
995
996
if __name__ == "__main__":
997
app = PanoramaPanel()
998
app.run(sys.argv)
999