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