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