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