main.py
Python script, ASCII text executable
1
"""
2
Panorama panel: traditional desktop panel using Wayland layer-shell
3
protocol.
4
Copyright 2025, roundabout-host.com <vlad@roundabout-host.com>
5
6
This program is free software: you can redistribute it and/or modify
7
it under the terms of the GNU General Public Licence as published by
8
the Free Software Foundation, either version 3 of the Licence, or
9
(at your option) any later version.
10
11
This program is distributed in the hope that it will be useful,
12
but WITHOUT ANY WARRANTY; without even the implied warranty of
13
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
GNU General Public Licence for more details.
15
16
You should have received a copy of the GNU General Public Licence
17
along with this program. If not, see <https://www.gnu.org/licenses/>.
18
"""
19
20
from __future__ import annotations
21
22
import os
23
import sys
24
import importlib
25
import time
26
import traceback
27
import faulthandler
28
import typing
29
import locale
30
from itertools import accumulate, chain
31
from pathlib import Path
32
import ruamel.yaml as yaml
33
from pywayland.client import Display, EventQueue
34
from pywayland.protocol.wayland.wl_output import WlOutputProxy
35
from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlOutput, WlShm
36
from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import (
37
ZwlrForeignToplevelManagerV1,
38
ZwlrForeignToplevelHandleV1
39
)
40
from pywayland.protocol.wlr_screencopy_unstable_v1 import (
41
ZwlrScreencopyManagerV1,
42
ZwlrScreencopyFrameV1
43
)
44
45
faulthandler.enable()
46
47
os.environ["GI_TYPELIB_PATH"] = "/usr/local/lib/x86_64-linux-gnu/girepository-1.0"
48
49
from ctypes import CDLL
50
CDLL("libgtk4-layer-shell.so")
51
52
import gi
53
gi.require_version("Gtk", "4.0")
54
gi.require_version("Gtk4LayerShell", "1.0")
55
56
from gi.repository import Gtk, GLib, Gtk4LayerShell, Gdk, Gio, GObject, Pango
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
module_directory = Path(__file__).resolve().parent
72
locale.bindtextdomain("panorama-panel", str(module_directory / "locale"))
73
locale.textdomain("panorama-panel")
74
locale.setlocale(locale.LC_ALL)
75
_ = locale.gettext
76
77
import panorama_panel
78
79
import asyncio
80
asyncio.set_event_loop_policy(GLibEventLoopPolicy())
81
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
class AppletChoice(Gtk.Box):
280
def __init__(self, applet_class, **kwargs):
281
super().__init__(**kwargs, orientation=Gtk.Orientation.VERTICAL)
282
283
self.label = Gtk.Label(label=applet_class.name, halign=Gtk.Align.CENTER, xalign=0.5, wrap_mode=Pango.WrapMode.WORD)
284
self.icon = Gtk.Image(gicon=applet_class.icon, icon_size=Gtk.IconSize.LARGE, halign=Gtk.Align.CENTER, hexpand=False)
285
self.append(self.icon)
286
self.append(self.label)
287
self.AppletClass = applet_class
288
289
self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.COPY)
290
self.add_controller(self.drag_source)
291
self.drag_source.connect("prepare", self.provide_drag_data)
292
self.drag_source.connect("drag-begin", self.drag_begin)
293
294
def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float):
295
app = self.get_root().get_application()
296
app.drags[id(self)] = self.AppletClass
297
value = GObject.Value()
298
value.init(GObject.TYPE_UINT64)
299
value.set_uint64(id(self))
300
return Gdk.ContentProvider.new_for_value(value)
301
302
def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag):
303
paintable = Gtk.WidgetPaintable.new(self.icon)
304
source.set_icon(paintable, 0, 0)
305
306
307
@Gtk.Template(filename=str(module_directory / "applet-listing.ui"))
308
class AppletListing(Gtk.Window):
309
__gtype_name__ = "AppletListing"
310
311
flowbox: Gtk.FlowBox = Gtk.Template.Child()
312
313
def __init__(self, application: PanoramaPanel, **kwargs):
314
super().__init__(application=application, **kwargs)
315
316
self.connect("close-request", lambda *args: self.destroy())
317
318
for class_name, applet in sorted(application.applets_by_name.items(), key=lambda x: x[0]):
319
applet_widget = AppletChoice(applet)
320
self.flowbox.append(applet_widget)
321
322
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
323
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
324
self.drop_target.connect("drop", self.drop_applet)
325
self.add_controller(self.drop_target)
326
327
def drop_applet(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
328
# Applet deletion
329
applet = self.get_root().get_application().drags[value]
330
331
if not isinstance(applet, panorama_panel.Applet):
332
return False
333
334
old_area: AppletArea = applet.get_parent()
335
if old_area:
336
old_area.remove(applet)
337
338
del applet
339
340
self.get_root().get_application().drags.pop(value)
341
self.get_root().get_application().save_config()
342
343
return True
344
345
346
def get_applet_directories():
347
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
348
data_dirs = [Path(d) for d in os.getenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":")]
349
350
all_paths = [data_home / "panorama-panel" / "applets"] + [d / "panorama-panel" / "applets" for d in data_dirs]
351
return [d for d in all_paths if d.is_dir()]
352
353
354
def get_config_file():
355
config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
356
357
return config_home / "panorama-panel" / "config.yaml"
358
359
360
class AppletArea(Gtk.Box):
361
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL):
362
super().__init__()
363
364
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE | Gdk.DragAction.COPY)
365
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
366
self.drop_target.connect("drop", self.drop_applet)
367
368
def drop_applet(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
369
obj = self.get_root().get_application().drags[value]
370
if isinstance(obj, type):
371
applet = obj(orientation=self.get_orientation(), config=None, application=self.get_root().get_application())
372
if self.get_root().get_application().edit_mode:
373
applet.make_draggable()
374
else:
375
applet.restore_drag()
376
applet.set_opacity(0.75 if value else 1)
377
elif isinstance(obj, panorama_panel.Applet):
378
applet = obj
379
else:
380
return False
381
old_area: AppletArea = applet.get_parent()
382
if old_area:
383
old_area.remove(applet)
384
# Find the position where to insert the applet
385
child = self.get_first_child()
386
while child:
387
allocation = child.get_allocation()
388
child_x, child_y = child.translate_coordinates(self, 0, 0)
389
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
390
midpoint = child_x + allocation.width / 2
391
if x < midpoint:
392
applet.insert_before(self, child)
393
break
394
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
395
midpoint = child_y + allocation.height / 2
396
if y < midpoint:
397
applet.insert_before(self, child)
398
break
399
child = child.get_next_sibling()
400
else:
401
self.append(applet)
402
applet.show()
403
404
self.get_root().get_application().drags.pop(value)
405
self.get_root().get_application().save_config()
406
407
return True
408
409
def set_edit_mode(self, value):
410
panel: Panel = self.get_root()
411
child = self.get_first_child()
412
while child is not None:
413
if value:
414
child.make_draggable()
415
else:
416
child.restore_drag()
417
child.set_opacity(0.75 if value else 1)
418
child = child.get_next_sibling()
419
420
if value:
421
self.add_controller(self.drop_target)
422
if panel.get_orientation() == Gtk.Orientation.HORIZONTAL:
423
self.set_size_request(48, 0)
424
elif panel.get_orientation() == Gtk.Orientation.VERTICAL:
425
self.set_size_request(0, 48)
426
else:
427
self.remove_controller(self.drop_target)
428
self.set_size_request(0, 0)
429
430
def flash(self):
431
self.add_css_class("panel-flash")
432
433
def unflash(self):
434
self.remove_css_class("panel-flash")
435
436
437
POSITION_TO_LAYER_SHELL_EDGE = {
438
Gtk.PositionType.TOP: Gtk4LayerShell.Edge.TOP,
439
Gtk.PositionType.BOTTOM: Gtk4LayerShell.Edge.BOTTOM,
440
Gtk.PositionType.LEFT: Gtk4LayerShell.Edge.LEFT,
441
Gtk.PositionType.RIGHT: Gtk4LayerShell.Edge.RIGHT,
442
}
443
444
445
class Panel(Gtk.Window):
446
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):
447
super().__init__(application=application)
448
self.drop_motion_controller = None
449
self.motion_controller = None
450
self.set_decorated(False)
451
self.position = None
452
self.autohide = None
453
self.monitor_name = monitor.get_connector()
454
self.hide_time = None
455
self.monitor_index = 0
456
self.can_capture_keyboard = can_capture_keyboard
457
self.open_popovers: set[int] = set()
458
459
self.add_css_class("panorama-panel")
460
461
Gtk4LayerShell.init_for_window(self)
462
Gtk4LayerShell.set_namespace(self, "com.roundabout_host.panorama.panel")
463
Gtk4LayerShell.set_monitor(self, monitor)
464
465
Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP)
466
if can_capture_keyboard:
467
Gtk4LayerShell.set_keyboard_mode(self, Gtk4LayerShell.KeyboardMode.ON_DEMAND)
468
else:
469
Gtk4LayerShell.set_keyboard_mode(self, Gtk4LayerShell.KeyboardMode.NONE)
470
471
box = Gtk.CenterBox()
472
473
self.left_area = AppletArea(orientation=box.get_orientation())
474
self.centre_area = AppletArea(orientation=box.get_orientation())
475
self.right_area = AppletArea(orientation=box.get_orientation())
476
477
box.set_start_widget(self.left_area)
478
box.set_center_widget(self.centre_area)
479
box.set_end_widget(self.right_area)
480
481
self.set_child(box)
482
self.set_position(position)
483
self.set_size(size)
484
self.set_autohide(autohide, hide_time)
485
486
# Add a context menu
487
menu = Gio.Menu()
488
489
menu.append(_("Open _manager"), "panel.manager")
490
menu.append(_("_Applets"), "panel.applets")
491
492
self.context_menu = Gtk.PopoverMenu.new_from_model(menu)
493
self.context_menu.set_has_arrow(False)
494
self.context_menu.set_parent(self)
495
self.context_menu.set_halign(Gtk.Align.START)
496
self.context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
497
panorama_panel.track_popover(self.context_menu)
498
499
right_click_controller = Gtk.GestureClick()
500
right_click_controller.set_button(3)
501
right_click_controller.connect("pressed", self.show_context_menu)
502
503
self.add_controller(right_click_controller)
504
505
action_group = Gio.SimpleActionGroup()
506
manager_action = Gio.SimpleAction.new("manager", None)
507
manager_action.connect("activate", self.show_manager)
508
action_group.add_action(manager_action)
509
applets_action = Gio.SimpleAction.new("applets", None)
510
applets_action.connect("activate", self.show_applets)
511
action_group.add_action(applets_action)
512
self.insert_action_group("panel", action_group)
513
514
def reset_margins(self):
515
for edge in POSITION_TO_LAYER_SHELL_EDGE.values():
516
Gtk4LayerShell.set_margin(self, edge, 0)
517
518
def set_edit_mode(self, value):
519
for area in (self.left_area, self.centre_area, self.right_area):
520
area.set_edit_mode(value)
521
522
def show_context_menu(self, gesture, n_presses, x, y):
523
rect = Gdk.Rectangle()
524
rect.x = int(x)
525
rect.y = int(y)
526
rect.width = 1
527
rect.height = 1
528
529
self.context_menu.set_pointing_to(rect)
530
self.context_menu.popup()
531
532
def slide_in(self):
533
if not self.autohide:
534
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 0)
535
return False
536
537
if Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) >= 0:
538
return False
539
540
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) + 1)
541
return True
542
543
def slide_out(self):
544
if not self.autohide:
545
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 0)
546
return False
547
548
if Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) <= 1 - self.size:
549
return False
550
551
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) - 1)
552
return True
553
554
def show_manager(self, _0=None, _1=None):
555
if self.get_application():
556
if not self.get_application().manager_window:
557
self.get_application().manager_window = PanelManager(self.get_application())
558
self.get_application().manager_window.connect("close-request", self.get_application().reset_manager_window)
559
self.get_application().manager_window.present()
560
561
def show_applets(self, _0=None, _1=None):
562
if self.get_application():
563
if not self.get_application().applets_window:
564
self.get_application().applets_window = AppletListing(self.get_application())
565
self.get_application().applets_window.connect("close-request", self.get_application().reset_applets_window)
566
self.get_application().applets_window.present()
567
568
def get_orientation(self):
569
box = self.get_first_child()
570
return box.get_orientation()
571
572
def set_size(self, value: int):
573
self.size = int(value)
574
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
575
self.set_size_request(800, self.size)
576
self.set_default_size(800, self.size)
577
else:
578
self.set_size_request(self.size, 600)
579
self.set_default_size(self.size, 600)
580
581
def set_position(self, position: Gtk.PositionType):
582
self.position = position
583
self.reset_margins()
584
match self.position:
585
case Gtk.PositionType.TOP:
586
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, False)
587
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
588
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
589
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True)
590
case Gtk.PositionType.BOTTOM:
591
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, False)
592
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
593
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
594
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True)
595
case Gtk.PositionType.LEFT:
596
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, False)
597
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
598
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
599
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
600
case Gtk.PositionType.RIGHT:
601
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, False)
602
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True)
603
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
604
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
605
box = self.get_first_child()
606
match self.position:
607
case Gtk.PositionType.TOP | Gtk.PositionType.BOTTOM:
608
box.set_orientation(Gtk.Orientation.HORIZONTAL)
609
for area in (self.left_area, self.centre_area, self.right_area):
610
area.set_orientation(Gtk.Orientation.HORIZONTAL)
611
case Gtk.PositionType.LEFT | Gtk.PositionType.RIGHT:
612
box.set_orientation(Gtk.Orientation.VERTICAL)
613
for area in (self.left_area, self.centre_area, self.right_area):
614
area.set_orientation(Gtk.Orientation.VERTICAL)
615
616
if self.autohide:
617
if not self.open_popovers:
618
GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_out)
619
620
def set_autohide(self, autohide: bool, hide_time: int):
621
self.autohide = autohide
622
self.hide_time = hide_time
623
if not self.autohide:
624
self.reset_margins()
625
Gtk4LayerShell.auto_exclusive_zone_enable(self)
626
if self.motion_controller is not None:
627
self.remove_controller(self.motion_controller)
628
if self.drop_motion_controller is not None:
629
self.remove_controller(self.drop_motion_controller)
630
self.motion_controller = None
631
self.drop_motion_controller = None
632
self.reset_margins()
633
else:
634
Gtk4LayerShell.set_exclusive_zone(self, 0)
635
# Only leave 1px of the window as a "mouse sensor"
636
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 1 - self.size)
637
self.motion_controller = Gtk.EventControllerMotion()
638
self.add_controller(self.motion_controller)
639
self.drop_motion_controller = Gtk.DropControllerMotion()
640
self.add_controller(self.drop_motion_controller)
641
642
self.motion_controller.connect("enter", lambda *args: GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_in))
643
self.drop_motion_controller.connect("enter", lambda *args: GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_in))
644
self.motion_controller.connect("leave", lambda *args: not self.open_popovers and GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_out))
645
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))
646
647
def wl_output_enter(self, output, name):
648
for area in (self.left_area, self.centre_area, self.right_area):
649
applet = area.get_first_child()
650
while applet:
651
applet.wl_output_enter(output, name)
652
applet = applet.get_next_sibling()
653
654
def foreign_toplevel_new(self, manager, toplevel):
655
for area in (self.left_area, self.centre_area, self.right_area):
656
applet = area.get_first_child()
657
while applet:
658
applet.foreign_toplevel_new(manager, toplevel)
659
applet = applet.get_next_sibling()
660
661
def foreign_toplevel_output_enter(self, toplevel, output):
662
for area in (self.left_area, self.centre_area, self.right_area):
663
applet = area.get_first_child()
664
while applet:
665
applet.foreign_toplevel_output_enter(toplevel, output)
666
applet = applet.get_next_sibling()
667
668
def foreign_toplevel_output_leave(self, toplevel, output):
669
for area in (self.left_area, self.centre_area, self.right_area):
670
applet = area.get_first_child()
671
while applet:
672
applet.foreign_toplevel_output_leave(toplevel, output)
673
applet = applet.get_next_sibling()
674
675
def foreign_toplevel_app_id(self, toplevel, app_id):
676
for area in (self.left_area, self.centre_area, self.right_area):
677
applet = area.get_first_child()
678
while applet:
679
applet.foreign_toplevel_app_id(toplevel, app_id)
680
applet = applet.get_next_sibling()
681
682
def foreign_toplevel_title(self, toplevel, title):
683
for area in (self.left_area, self.centre_area, self.right_area):
684
applet = area.get_first_child()
685
while applet:
686
applet.foreign_toplevel_title(toplevel, title)
687
applet = applet.get_next_sibling()
688
689
def foreign_toplevel_state(self, toplevel, state):
690
for area in (self.left_area, self.centre_area, self.right_area):
691
applet = area.get_first_child()
692
while applet:
693
applet.foreign_toplevel_state(toplevel, state)
694
applet = applet.get_next_sibling()
695
696
def foreign_toplevel_closed(self, toplevel):
697
for area in (self.left_area, self.centre_area, self.right_area):
698
applet = area.get_first_child()
699
while applet:
700
applet.foreign_toplevel_closed(toplevel)
701
applet = applet.get_next_sibling()
702
703
def foreign_toplevel_refresh(self):
704
for area in (self.left_area, self.centre_area, self.right_area):
705
applet = area.get_first_child()
706
while applet:
707
applet.foreign_toplevel_refresh()
708
applet = applet.get_next_sibling()
709
710
711
def get_all_subclasses(klass: type) -> list[type]:
712
subclasses = []
713
for subclass in klass.__subclasses__():
714
subclasses.append(subclass)
715
subclasses += get_all_subclasses(subclass)
716
717
return subclasses
718
719
def load_packages_from_dir(dir_path: Path):
720
loaded_modules = []
721
722
for path in dir_path.iterdir():
723
if path.name.startswith("_"):
724
continue
725
726
if path.is_dir() and (path / "__init__.py").exists():
727
try:
728
module_name = path.name
729
spec = importlib.util.spec_from_file_location(module_name, path / "__init__.py")
730
module = importlib.util.module_from_spec(spec)
731
module.__package__ = module_name
732
sys.modules[module_name] = module
733
spec.loader.exec_module(module)
734
loaded_modules.append(module)
735
except Exception as e:
736
print(f"Failed to load applet module {path.name}: {e}", file=sys.stderr)
737
traceback.print_exc()
738
else:
739
continue
740
741
return loaded_modules
742
743
744
PANEL_POSITIONS = {
745
"top": Gtk.PositionType.TOP,
746
"bottom": Gtk.PositionType.BOTTOM,
747
"left": Gtk.PositionType.LEFT,
748
"right": Gtk.PositionType.RIGHT,
749
}
750
751
752
PANEL_POSITIONS_REVERSE = {
753
Gtk.PositionType.TOP: "top",
754
Gtk.PositionType.BOTTOM: "bottom",
755
Gtk.PositionType.LEFT: "left",
756
Gtk.PositionType.RIGHT: "right",
757
}
758
759
760
class PanoramaPanel(Gtk.Application):
761
def __init__(self):
762
super().__init__(
763
application_id="com.roundabout_host.roundabout.PanoramaPanel",
764
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE
765
)
766
self.display = Gdk.Display.get_default()
767
self.monitors = self.display.get_monitors()
768
self.applets_by_name: dict[str, panorama_panel.Applet] = {}
769
self.panels: list[Panel] = []
770
self.wl_globals = []
771
self.wl_display = None
772
self.wl_seat = None
773
self.wl_shm = None
774
self.foreign_toplevel_manager = None
775
self.screencopy_manager = None
776
self.manager_window = None
777
self.applets_window = None
778
self.edit_mode = False
779
self.output_proxies = []
780
self.live_preview_output = None
781
self.drags = {}
782
self.wl_output_ids = set()
783
self.started = False
784
self.all_handles: set[ZwlrForeignToplevelHandleV1] = set()
785
786
self.add_main_option(
787
"trigger",
788
ord("t"),
789
GLib.OptionFlags.NONE,
790
GLib.OptionArg.STRING,
791
_("Trigger an action which can be processed by the applets"),
792
_("NAME")
793
)
794
795
def destroy_foreign_toplevel_manager(self):
796
self.foreign_toplevel_manager.destroy()
797
self.wl_globals.remove(self.foreign_toplevel_manager)
798
self.foreign_toplevel_manager = None
799
self.foreign_toplevel_refresh()
800
801
def on_foreign_toplevel_manager_finish(self, manager: ZwlrForeignToplevelManagerV1):
802
if self.foreign_toplevel_manager:
803
self.destroy_foreign_toplevel_manager()
804
805
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
806
handle: ZwlrForeignToplevelHandleV1):
807
self.all_handles.add(handle)
808
809
for panel in self.panels:
810
panel.foreign_toplevel_new(manager, handle)
811
handle.dispatcher["title"] = self.on_title_changed
812
handle.dispatcher["app_id"] = self.on_app_id_changed
813
handle.dispatcher["output_enter"] = self.on_output_enter
814
handle.dispatcher["output_leave"] = self.on_output_leave
815
handle.dispatcher["state"] = self.on_state_changed
816
handle.dispatcher["closed"] = self.on_closed
817
818
def on_output_enter(self, handle, output):
819
for panel in self.panels:
820
panel.foreign_toplevel_output_enter(handle, output)
821
822
def on_output_leave(self, handle, output):
823
for panel in self.panels:
824
panel.foreign_toplevel_output_leave(handle, output)
825
826
def on_title_changed(self, handle, title):
827
for panel in self.panels:
828
panel.foreign_toplevel_title(handle, title)
829
830
def on_app_id_changed(self, handle, app_id):
831
for panel in self.panels:
832
panel.foreign_toplevel_app_id(handle, app_id)
833
834
def on_state_changed(self, handle, state):
835
for panel in self.panels:
836
panel.foreign_toplevel_state(handle, state)
837
838
def on_closed(self, handle):
839
for panel in self.panels:
840
panel.foreign_toplevel_closed(handle)
841
handle.destroy()
842
self.all_handles.discard(handle)
843
844
def foreign_toplevel_refresh(self):
845
for handle in self.all_handles:
846
handle.destroy()
847
848
self.all_handles = set()
849
850
for panel in self.panels:
851
panel.foreign_toplevel_refresh()
852
853
def receive_output_name(self, output: WlOutput, name: str):
854
output.name = name
855
856
def idle_proxy_cover(self, output_name):
857
for monitor in self.display.get_monitors():
858
wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
859
found = False
860
for proxy in self.output_proxies:
861
if proxy._ptr == wl_output:
862
found = True
863
if wl_output and not found:
864
print("Create proxy")
865
output_proxy = WlOutputProxy(wl_output, self.wl_display)
866
output_proxy.interface.registry[output_proxy._ptr] = output_proxy
867
self.output_proxies.append(output_proxy)
868
869
def on_global(self, registry, name, interface, version):
870
if interface == "zwlr_foreign_toplevel_manager_v1":
871
self.foreign_toplevel_manager_id = name
872
self.foreign_toplevel_version = version
873
elif interface == "wl_output":
874
output = registry.bind(name, WlOutput, version)
875
output.id = name
876
self.wl_globals.append(output)
877
print(f"global {name} {interface} {version}")
878
output.global_name = name
879
output.name = None
880
output.dispatcher["name"] = self.receive_output_name
881
while not output.name or not self.foreign_toplevel_version:
882
self.wl_display.dispatch(block=True)
883
print(output.name)
884
self.wl_output_ids.add(output.global_name)
885
if not output.name.startswith("live-preview"):
886
self.generate_panels(output.name)
887
for panel in self.panels:
888
panel.wl_output_enter(output, output.name)
889
if not output.name.startswith("live-preview"):
890
if self.foreign_toplevel_manager:
891
self.foreign_toplevel_manager.stop()
892
self.wl_display.roundtrip()
893
if self.foreign_toplevel_manager:
894
self.destroy_foreign_toplevel_manager()
895
self.foreign_toplevel_manager = self.registry.bind(self.foreign_toplevel_manager_id, ZwlrForeignToplevelManagerV1, self.foreign_toplevel_version)
896
self.foreign_toplevel_manager.id = self.foreign_toplevel_manager_id
897
self.wl_globals.append(self.foreign_toplevel_manager)
898
self.foreign_toplevel_manager.dispatcher["toplevel"] = self.on_new_toplevel
899
self.foreign_toplevel_manager.dispatcher["finished"] = self.on_foreign_toplevel_manager_finish
900
self.wl_display.roundtrip()
901
else:
902
self.live_preview_output = output
903
print(f"Monitor {output.name} ({interface}) connected", file=sys.stderr)
904
elif interface == "wl_seat":
905
self.wl_seat = registry.bind(name, WlSeat, version)
906
self.wl_seat.id = name
907
self.wl_globals.append(self.wl_seat)
908
elif interface == "wl_shm":
909
self.wl_shm = registry.bind(name, WlShm, version)
910
self.wl_shm.id = name
911
self.wl_globals.append(self.wl_shm)
912
elif interface == "zwlr_screencopy_manager_v1":
913
self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version)
914
self.screencopy_manager.id = name
915
self.wl_globals.append(self.screencopy_manager)
916
917
def on_global_remove(self, registry, name):
918
if name in self.wl_output_ids:
919
print(f"Monitor {name} disconnected", file=sys.stderr)
920
self.wl_output_ids.discard(name)
921
for g in self.wl_globals:
922
if g.id == name:
923
g.destroy()
924
self.wl_globals.remove(g)
925
break
926
927
def generate_panels(self, output_name):
928
print("Generating panels...", file=sys.stderr)
929
930
for i, monitor in enumerate(self.monitors):
931
geometry = monitor.get_geometry()
932
print(f"Monitor {i}: {geometry.width}x{geometry.height} at {geometry.x},{geometry.y}")
933
934
new_panels = []
935
for panel in self.panels:
936
if panel.monitor_name == output_name:
937
for area in (panel.left_area, panel.centre_area, panel.right_area):
938
applet = area.get_first_child()
939
while applet is not None:
940
applet.shutdown(self)
941
942
applet = applet.get_next_sibling()
943
panel.destroy()
944
else:
945
new_panels.append(panel)
946
self.panels = new_panels
947
with open(get_config_file(), "r") as config_file:
948
yaml_loader = yaml.YAML(typ="rt")
949
yaml_file = yaml_loader.load(config_file)
950
for panel_data in yaml_file["panels"]:
951
if panel_data["monitor"] != output_name:
952
continue
953
position = PANEL_POSITIONS[panel_data["position"]]
954
my_monitor = None
955
for monitor in self.display.get_monitors():
956
if monitor.get_connector() == panel_data["monitor"]:
957
my_monitor = monitor
958
break
959
if not my_monitor:
960
continue
961
GLib.idle_add(self.idle_proxy_cover, my_monitor.get_connector())
962
size = panel_data["size"]
963
autohide = panel_data["autohide"]
964
hide_time = panel_data["hide_time"]
965
can_capture_keyboard = panel_data["can_capture_keyboard"]
966
967
panel = Panel(self, my_monitor, position, size, autohide, hide_time, can_capture_keyboard)
968
self.panels.append(panel)
969
970
print(f"{size}px panel on {position}, autohide is {autohide} ({hide_time}ms)")
971
972
for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)):
973
applet_list = panel_data["applets"].get(area_name)
974
if applet_list is None:
975
continue
976
977
for applet in applet_list:
978
item = list(applet.items())[0]
979
AppletClass = self.applets_by_name[item[0]]
980
options = item[1]
981
applet_widget = AppletClass(orientation=panel.get_orientation(), config=options, application=self)
982
applet_widget.connect("config-changed", self.save_config)
983
applet_widget.set_panel_position(panel.position)
984
985
area.append(applet_widget)
986
987
panel.present()
988
panel.realize()
989
panel.monitor_name = my_monitor.get_connector()
990
if self.live_preview_output:
991
panel.wl_output_enter(self.live_preview_output, self.live_preview_output.name)
992
993
def do_startup(self):
994
Gtk.Application.do_startup(self)
995
996
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
997
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
998
999
all_applets = list(
1000
chain.from_iterable(load_packages_from_dir(d) for d in get_applet_directories()))
1001
print("Applets:")
1002
subclasses = get_all_subclasses(panorama_panel.Applet)
1003
for subclass in subclasses:
1004
if subclass.__name__ in self.applets_by_name:
1005
print(f"Name conflict for applet {subclass.__name__}. Only one will be loaded.",
1006
file=sys.stderr)
1007
self.applets_by_name[subclass.__name__] = subclass
1008
1009
self.wl_display = Display()
1010
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
1011
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None)))
1012
self.wl_display._ptr = wl_display_ptr
1013
1014
self.foreign_toplevel_manager_id = None
1015
self.foreign_toplevel_version = None
1016
1017
self.idle_proxy_cover(None)
1018
self.registry = self.wl_display.get_registry()
1019
self.registry.dispatcher["global"] = self.on_global
1020
self.registry.dispatcher["global_remove"] = self.on_global_remove
1021
self.wl_display.roundtrip()
1022
if self.foreign_toplevel_version:
1023
print("Foreign Toplevel Interface: Found")
1024
else:
1025
print("Foreign Toplevel Interface: Not Found")
1026
1027
self.hold()
1028
1029
def do_activate(self):
1030
Gio.Application.do_activate(self)
1031
1032
def save_config(self, *args):
1033
print("Saving configuration file")
1034
with open(get_config_file(), "w") as config_file:
1035
yaml_writer = yaml.YAML(typ="rt")
1036
data = {"panels": []}
1037
for panel in self.panels:
1038
panel_data = {
1039
"position": PANEL_POSITIONS_REVERSE[panel.position],
1040
"monitor": panel.monitor_name,
1041
"size": panel.size,
1042
"autohide": panel.autohide,
1043
"hide_time": panel.hide_time,
1044
"can_capture_keyboard": panel.can_capture_keyboard,
1045
"applets": {}
1046
}
1047
1048
for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)):
1049
panel_data["applets"][area_name] = []
1050
applet = area.get_first_child()
1051
while applet is not None:
1052
panel_data["applets"][area_name].append({
1053
applet.__class__.__name__: applet.get_config(),
1054
})
1055
1056
applet = applet.get_next_sibling()
1057
1058
data["panels"].append(panel_data)
1059
1060
yaml_writer.dump(data, config_file)
1061
1062
def exit_all_applets(self):
1063
for panel in self.panels:
1064
for area in (panel.left_area, panel.centre_area, panel.right_area):
1065
applet = area.get_first_child()
1066
while applet is not None:
1067
applet.shutdown(self)
1068
1069
applet = applet.get_next_sibling()
1070
1071
def do_shutdown(self):
1072
print("Shutting down")
1073
self.exit_all_applets()
1074
Gtk.Application.do_shutdown(self)
1075
1076
def do_command_line(self, command_line: Gio.ApplicationCommandLine):
1077
options = command_line.get_options_dict()
1078
args = command_line.get_arguments()[1:]
1079
1080
trigger_variant = options.lookup_value("trigger", GLib.VariantType("s"))
1081
if trigger_variant:
1082
action_name = trigger_variant.get_string()
1083
print(app.list_actions())
1084
self.activate_action(action_name, None)
1085
print(f"Triggered {action_name}")
1086
1087
return 0
1088
1089
def set_edit_mode(self, value):
1090
self.edit_mode = value
1091
for panel in self.panels:
1092
panel.set_edit_mode(value)
1093
1094
def reset_manager_window(self, *args):
1095
self.manager_window = None
1096
1097
def reset_applets_window(self, *args):
1098
self.applets_window = None
1099
1100
1101
if __name__ == "__main__":
1102
app = PanoramaPanel()
1103
app.run(sys.argv)
1104