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.drags = {}
781
self.wl_output_ids = set()
782
self.started = False
783
self.all_handles: set[ZwlrForeignToplevelHandleV1] = set()
784
self.obsolete_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
if handle in self.obsolete_handles:
820
return
821
for panel in self.panels:
822
panel.foreign_toplevel_output_enter(handle, output)
823
824
def on_output_leave(self, handle, output):
825
if handle in self.obsolete_handles:
826
return
827
for panel in self.panels:
828
panel.foreign_toplevel_output_leave(handle, output)
829
830
def on_title_changed(self, handle, title):
831
if handle in self.obsolete_handles:
832
return
833
for panel in self.panels:
834
panel.foreign_toplevel_title(handle, title)
835
836
def on_app_id_changed(self, handle, app_id):
837
if handle in self.obsolete_handles:
838
return
839
for panel in self.panels:
840
panel.foreign_toplevel_app_id(handle, app_id)
841
842
def on_state_changed(self, handle, state):
843
if handle in self.obsolete_handles:
844
return
845
for panel in self.panels:
846
panel.foreign_toplevel_state(handle, state)
847
848
def on_closed(self, handle):
849
if handle in self.obsolete_handles:
850
return
851
for panel in self.panels:
852
panel.foreign_toplevel_closed(handle)
853
854
def foreign_toplevel_refresh(self):
855
self.obsolete_handles = self.all_handles.copy()
856
857
for panel in self.panels:
858
panel.foreign_toplevel_refresh()
859
860
def receive_output_name(self, output: WlOutput, name: str):
861
output.name = name
862
863
def idle_proxy_cover(self, output_name):
864
for monitor in self.display.get_monitors():
865
wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
866
found = False
867
for proxy in self.output_proxies:
868
if proxy._ptr == wl_output:
869
found = True
870
if wl_output and not found:
871
print("Create proxy")
872
output_proxy = WlOutputProxy(wl_output, self.wl_display)
873
output_proxy.interface.registry[output_proxy._ptr] = output_proxy
874
self.output_proxies.append(output_proxy)
875
876
def on_global(self, registry, name, interface, version):
877
if interface == "zwlr_foreign_toplevel_manager_v1":
878
self.foreign_toplevel_manager_id = name
879
self.foreign_toplevel_version = version
880
elif interface == "wl_output":
881
output = registry.bind(name, WlOutput, version)
882
output.id = name
883
self.wl_globals.append(output)
884
print(f"global {name} {interface} {version}")
885
output.global_name = name
886
output.name = None
887
output.dispatcher["name"] = self.receive_output_name
888
while not output.name or not self.foreign_toplevel_version:
889
self.wl_display.dispatch(block=True)
890
print(output.name)
891
self.wl_output_ids.add(output.global_name)
892
if not output.name.startswith("live-preview"):
893
self.generate_panels(output.name)
894
for panel in self.panels:
895
panel.wl_output_enter(output, output.name)
896
if not output.name.startswith("live-preview"):
897
if self.foreign_toplevel_manager:
898
self.foreign_toplevel_manager.stop()
899
self.wl_display.roundtrip()
900
if self.foreign_toplevel_manager:
901
self.destroy_foreign_toplevel_manager()
902
self.foreign_toplevel_manager = self.registry.bind(self.foreign_toplevel_manager_id, ZwlrForeignToplevelManagerV1, self.foreign_toplevel_version)
903
self.foreign_toplevel_manager.id = self.foreign_toplevel_manager_id
904
self.wl_globals.append(self.foreign_toplevel_manager)
905
self.foreign_toplevel_manager.dispatcher["toplevel"] = self.on_new_toplevel
906
self.foreign_toplevel_manager.dispatcher["finished"] = self.on_foreign_toplevel_manager_finish
907
self.wl_display.roundtrip()
908
print(f"Monitor {output.name} ({interface}) connected", file=sys.stderr)
909
elif interface == "wl_seat":
910
self.wl_seat = registry.bind(name, WlSeat, version)
911
self.wl_seat.id = name
912
self.wl_globals.append(self.wl_seat)
913
elif interface == "wl_shm":
914
self.wl_shm = registry.bind(name, WlShm, version)
915
self.wl_shm.id = name
916
self.wl_globals.append(self.wl_shm)
917
elif interface == "zwlr_screencopy_manager_v1":
918
self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version)
919
self.screencopy_manager.id = name
920
self.wl_globals.append(self.screencopy_manager)
921
922
def on_global_remove(self, registry, name):
923
if name in self.wl_output_ids:
924
print(f"Monitor {name} disconnected", file=sys.stderr)
925
self.wl_output_ids.discard(name)
926
for g in self.wl_globals:
927
if g.id == name:
928
g.destroy()
929
self.wl_globals.remove(g)
930
break
931
932
def generate_panels(self, output_name):
933
print("Generating panels...", file=sys.stderr)
934
935
for i, monitor in enumerate(self.monitors):
936
geometry = monitor.get_geometry()
937
print(f"Monitor {i}: {geometry.width}x{geometry.height} at {geometry.x},{geometry.y}")
938
939
for panel in self.panels:
940
if panel.monitor_name == output_name:
941
for area in (panel.left_area, panel.centre_area, panel.right_area):
942
applet = area.get_first_child()
943
while applet is not None:
944
applet.shutdown(self)
945
946
applet = applet.get_next_sibling()
947
panel.destroy()
948
with open(get_config_file(), "r") as config_file:
949
yaml_loader = yaml.YAML(typ="rt")
950
yaml_file = yaml_loader.load(config_file)
951
for panel_data in yaml_file["panels"]:
952
if panel_data["monitor"] != output_name:
953
continue
954
position = PANEL_POSITIONS[panel_data["position"]]
955
my_monitor = None
956
for monitor in self.display.get_monitors():
957
if monitor.get_connector() == panel_data["monitor"]:
958
my_monitor = monitor
959
break
960
if not my_monitor:
961
continue
962
GLib.idle_add(self.idle_proxy_cover, my_monitor.get_connector())
963
size = panel_data["size"]
964
autohide = panel_data["autohide"]
965
hide_time = panel_data["hide_time"]
966
can_capture_keyboard = panel_data["can_capture_keyboard"]
967
968
panel = Panel(self, my_monitor, position, size, autohide, hide_time, can_capture_keyboard)
969
self.panels.append(panel)
970
971
print(f"{size}px panel on {position}, autohide is {autohide} ({hide_time}ms)")
972
973
for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)):
974
applet_list = panel_data["applets"].get(area_name)
975
if applet_list is None:
976
continue
977
978
for applet in applet_list:
979
item = list(applet.items())[0]
980
AppletClass = self.applets_by_name[item[0]]
981
options = item[1]
982
applet_widget = AppletClass(orientation=panel.get_orientation(), config=options, application=self)
983
applet_widget.connect("config-changed", self.save_config)
984
applet_widget.set_panel_position(panel.position)
985
986
area.append(applet_widget)
987
988
panel.present()
989
panel.realize()
990
panel.monitor_name = my_monitor.get_connector()
991
992
def do_startup(self):
993
Gtk.Application.do_startup(self)
994
995
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
996
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
997
998
all_applets = list(
999
chain.from_iterable(load_packages_from_dir(d) for d in get_applet_directories()))
1000
print("Applets:")
1001
subclasses = get_all_subclasses(panorama_panel.Applet)
1002
for subclass in subclasses:
1003
if subclass.__name__ in self.applets_by_name:
1004
print(f"Name conflict for applet {subclass.__name__}. Only one will be loaded.",
1005
file=sys.stderr)
1006
self.applets_by_name[subclass.__name__] = subclass
1007
1008
self.wl_display = Display()
1009
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
1010
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None)))
1011
self.wl_display._ptr = wl_display_ptr
1012
1013
self.foreign_toplevel_manager_id = None
1014
self.foreign_toplevel_version = None
1015
1016
self.idle_proxy_cover(None)
1017
self.registry = self.wl_display.get_registry()
1018
self.registry.dispatcher["global"] = self.on_global
1019
self.registry.dispatcher["global_remove"] = self.on_global_remove
1020
self.wl_display.roundtrip()
1021
if self.foreign_toplevel_version:
1022
print("Foreign Toplevel Interface: Found")
1023
else:
1024
print("Foreign Toplevel Interface: Not Found")
1025
1026
self.hold()
1027
1028
def do_activate(self):
1029
Gio.Application.do_activate(self)
1030
1031
def save_config(self, *args):
1032
print("Saving configuration file")
1033
with open(get_config_file(), "w") as config_file:
1034
yaml_writer = yaml.YAML(typ="rt")
1035
data = {"panels": []}
1036
for panel in self.panels:
1037
panel_data = {
1038
"position": PANEL_POSITIONS_REVERSE[panel.position],
1039
"monitor": panel.monitor_name,
1040
"size": panel.size,
1041
"autohide": panel.autohide,
1042
"hide_time": panel.hide_time,
1043
"can_capture_keyboard": panel.can_capture_keyboard,
1044
"applets": {}
1045
}
1046
1047
for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)):
1048
panel_data["applets"][area_name] = []
1049
applet = area.get_first_child()
1050
while applet is not None:
1051
panel_data["applets"][area_name].append({
1052
applet.__class__.__name__: applet.get_config(),
1053
})
1054
1055
applet = applet.get_next_sibling()
1056
1057
data["panels"].append(panel_data)
1058
1059
yaml_writer.dump(data, config_file)
1060
1061
def exit_all_applets(self):
1062
for panel in self.panels:
1063
for area in (panel.left_area, panel.centre_area, panel.right_area):
1064
applet = area.get_first_child()
1065
while applet is not None:
1066
applet.shutdown(self)
1067
1068
applet = applet.get_next_sibling()
1069
1070
def do_shutdown(self):
1071
print("Shutting down")
1072
self.exit_all_applets()
1073
Gtk.Application.do_shutdown(self)
1074
1075
def do_command_line(self, command_line: Gio.ApplicationCommandLine):
1076
options = command_line.get_options_dict()
1077
args = command_line.get_arguments()[1:]
1078
1079
trigger_variant = options.lookup_value("trigger", GLib.VariantType("s"))
1080
if trigger_variant:
1081
action_name = trigger_variant.get_string()
1082
print(app.list_actions())
1083
self.activate_action(action_name, None)
1084
print(f"Triggered {action_name}")
1085
1086
return 0
1087
1088
def set_edit_mode(self, value):
1089
self.edit_mode = value
1090
for panel in self.panels:
1091
panel.set_edit_mode(value)
1092
1093
def reset_manager_window(self, *args):
1094
self.manager_window = None
1095
1096
def reset_applets_window(self, *args):
1097
self.applets_window = None
1098
1099
1100
if __name__ == "__main__":
1101
app = PanoramaPanel()
1102
app.run(sys.argv)
1103