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
locale.bindtextdomain("panorama-panel", "locale")
72
locale.textdomain("panorama-panel")
73
locale.setlocale(locale.LC_ALL)
74
_ = locale.gettext
75
76
import panorama_panel
77
78
import asyncio
79
asyncio.set_event_loop_policy(GLibEventLoopPolicy())
80
81
module_directory = Path(__file__).resolve().parent
82
83
custom_css = """
84
.panel-flash {
85
animation: flash 333ms ease-in-out 0s 2;
86
}
87
88
@keyframes flash {
89
0% {
90
background-color: initial;
91
}
92
50% {
93
background-color: #ffff0080;
94
}
95
0% {
96
background-color: initial;
97
}
98
}
99
"""
100
101
css_provider = Gtk.CssProvider()
102
css_provider.load_from_data(custom_css)
103
Gtk.StyleContext.add_provider_for_display(
104
Gdk.Display.get_default(),
105
css_provider,
106
100
107
)
108
109
110
@Gtk.Template(filename=str(module_directory / "panel-configurator.ui"))
111
class PanelConfigurator(Gtk.Frame):
112
__gtype_name__ = "PanelConfigurator"
113
114
panel_size_adjustment: Gtk.Adjustment = Gtk.Template.Child()
115
monitor_number_adjustment: Gtk.Adjustment = Gtk.Template.Child()
116
top_position_radio: Gtk.CheckButton = Gtk.Template.Child()
117
bottom_position_radio: Gtk.CheckButton = Gtk.Template.Child()
118
left_position_radio: Gtk.CheckButton = Gtk.Template.Child()
119
right_position_radio: Gtk.CheckButton = Gtk.Template.Child()
120
autohide_switch: Gtk.Switch = Gtk.Template.Child()
121
122
def __init__(self, panel: Panel, **kwargs):
123
super().__init__(**kwargs)
124
self.panel = panel
125
self.panel_size_adjustment.set_value(panel.size)
126
127
match self.panel.position:
128
case Gtk.PositionType.TOP:
129
self.top_position_radio.set_active(True)
130
case Gtk.PositionType.BOTTOM:
131
self.bottom_position_radio.set_active(True)
132
case Gtk.PositionType.LEFT:
133
self.left_position_radio.set_active(True)
134
case Gtk.PositionType.RIGHT:
135
self.right_position_radio.set_active(True)
136
137
self.top_position_radio.panel_position_target = Gtk.PositionType.TOP
138
self.bottom_position_radio.panel_position_target = Gtk.PositionType.BOTTOM
139
self.left_position_radio.panel_position_target = Gtk.PositionType.LEFT
140
self.right_position_radio.panel_position_target = Gtk.PositionType.RIGHT
141
self.autohide_switch.set_active(self.panel.autohide)
142
143
@Gtk.Template.Callback()
144
def update_panel_size(self, adjustment: Gtk.Adjustment):
145
if not self.get_root():
146
return
147
self.panel.set_size(int(adjustment.get_value()))
148
149
self.panel.get_application().save_config()
150
151
@Gtk.Template.Callback()
152
def toggle_autohide(self, switch: Gtk.Switch, value: bool):
153
if not self.get_root():
154
return
155
self.panel.set_autohide(value, self.panel.hide_time)
156
157
self.panel.get_application().save_config()
158
159
@Gtk.Template.Callback()
160
def move_panel(self, button: Gtk.CheckButton):
161
if not self.get_root():
162
return
163
if not button.get_active():
164
return
165
166
self.panel.set_position(button.panel_position_target)
167
self.update_panel_size(self.panel_size_adjustment)
168
169
# Make the applets aware of the changed orientation
170
for area in (self.panel.left_area, self.panel.centre_area, self.panel.right_area):
171
applet = area.get_first_child()
172
while applet:
173
applet.set_orientation(self.panel.get_orientation())
174
applet.set_panel_position(self.panel.position)
175
applet.queue_resize()
176
applet = applet.get_next_sibling()
177
178
self.panel.get_application().save_config()
179
180
@Gtk.Template.Callback()
181
def move_to_monitor(self, adjustment: Gtk.Adjustment):
182
if not self.get_root():
183
return
184
app: PanoramaPanel = self.get_root().get_application()
185
monitor = app.monitors[int(self.monitor_number_adjustment.get_value())]
186
self.panel.unmap()
187
self.panel.monitor_index = int(self.monitor_number_adjustment.get_value())
188
Gtk4LayerShell.set_monitor(self.panel, monitor)
189
self.panel.show()
190
191
# Make the applets aware of the changed monitor
192
for area in (self.panel.left_area, self.panel.centre_area, self.panel.right_area):
193
applet = area.get_first_child()
194
while applet:
195
applet.output_changed()
196
applet = applet.get_next_sibling()
197
198
self.panel.get_application().save_config()
199
200
201
PANEL_POSITIONS_HUMAN = {
202
Gtk.PositionType.TOP: "top",
203
Gtk.PositionType.BOTTOM: "bottom",
204
Gtk.PositionType.LEFT: "left",
205
Gtk.PositionType.RIGHT: "right",
206
}
207
208
209
@Gtk.Template(filename=str(module_directory / "panel-manager.ui"))
210
class PanelManager(Gtk.Window):
211
__gtype_name__ = "PanelManager"
212
213
panel_editing_switch: Gtk.Switch = Gtk.Template.Child()
214
panel_stack: Gtk.Stack = Gtk.Template.Child()
215
current_panel: typing.Optional[Panel] = None
216
217
def __init__(self, application: Gtk.Application, **kwargs):
218
super().__init__(application=application, **kwargs)
219
220
self.connect("close-request", lambda *args: self.destroy())
221
222
action_group = Gio.SimpleActionGroup()
223
224
self.next_panel_action = Gio.SimpleAction(name="next-panel")
225
action_group.add_action(self.next_panel_action)
226
self.next_panel_action.connect("activate", lambda *args: self.panel_stack.set_visible_child(self.panel_stack.get_visible_child().get_next_sibling()))
227
228
self.previous_panel_action = Gio.SimpleAction(name="previous-panel")
229
action_group.add_action(self.previous_panel_action)
230
self.previous_panel_action.connect("activate", lambda *args: self.panel_stack.set_visible_child(self.panel_stack.get_visible_child().get_prev_sibling()))
231
232
self.insert_action_group("win", action_group)
233
if isinstance(self.get_application(), PanoramaPanel):
234
self.panel_editing_switch.set_active(application.edit_mode)
235
self.panel_editing_switch.connect("state-set", self.set_edit_mode)
236
237
self.connect("close-request", lambda *args: self.unflash_old_panel())
238
self.connect("close-request", lambda *args: self.destroy())
239
240
if isinstance(self.get_application(), PanoramaPanel):
241
app: PanoramaPanel = self.get_application()
242
for panel in app.panels:
243
configurator = PanelConfigurator(panel)
244
self.panel_stack.add_child(configurator)
245
configurator.monitor_number_adjustment.set_upper(len(app.monitors))
246
247
self.panel_stack.set_visible_child(self.panel_stack.get_first_child())
248
self.panel_stack.connect("notify::visible-child", self.set_visible_panel)
249
self.panel_stack.notify("visible-child")
250
251
def unflash_old_panel(self):
252
if self.current_panel:
253
for area in (self.current_panel.left_area, self.current_panel.centre_area, self.current_panel.right_area):
254
area.unflash()
255
256
def set_visible_panel(self, stack: Gtk.Stack, pspec: GObject.ParamSpec):
257
self.unflash_old_panel()
258
259
panel: Panel = stack.get_visible_child().panel
260
261
self.current_panel = panel
262
self.next_panel_action.set_enabled(stack.get_visible_child().get_next_sibling() is not None)
263
self.previous_panel_action.set_enabled(stack.get_visible_child().get_prev_sibling() is not None)
264
265
# Start an animation to show the user what panel is being edited
266
for area in (panel.left_area, panel.centre_area, panel.right_area):
267
area.flash()
268
269
def set_edit_mode(self, switch, value):
270
if isinstance(self.get_application(), PanoramaPanel):
271
self.get_application().set_edit_mode(value)
272
273
@Gtk.Template.Callback()
274
def save_settings(self, *args):
275
if isinstance(self.get_application(), PanoramaPanel):
276
self.get_application().save_config()
277
278
279
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.append(self.label)
285
self.AppletClass = applet_class
286
287
self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.COPY)
288
self.add_controller(self.drag_source)
289
self.drag_source.connect("prepare", self.provide_drag_data)
290
self.drag_source.connect("drag-begin", self.drag_begin)
291
292
def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float):
293
app = self.get_root().get_application()
294
print(self.AppletClass.name)
295
app.drags[id(self)] = self.AppletClass
296
value = GObject.Value()
297
value.init(GObject.TYPE_UINT64)
298
value.set_uint64(id(self))
299
print(value)
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).get_current_image()
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
323
def get_applet_directories():
324
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
325
data_dirs = [Path(d) for d in os.getenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":")]
326
327
all_paths = [data_home / "panorama-panel" / "applets"] + [d / "panorama-panel" / "applets" for d in data_dirs]
328
return [d for d in all_paths if d.is_dir()]
329
330
331
def get_config_file():
332
config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
333
334
return config_home / "panorama-panel" / "config.yaml"
335
336
337
class AppletArea(Gtk.Box):
338
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL):
339
super().__init__()
340
341
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE | Gdk.DragAction.COPY)
342
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
343
self.drop_target.connect("drop", self.drop_applet)
344
345
def drop_applet(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
346
print(value)
347
obj = self.get_root().get_application().drags[value]
348
if isinstance(obj, type):
349
applet = obj()
350
if self.get_root().get_application().edit_mode:
351
applet.make_draggable()
352
else:
353
applet.restore_drag()
354
applet.set_opacity(0.75 if value else 1)
355
else:
356
applet = obj
357
old_area: AppletArea = applet.get_parent()
358
if old_area:
359
old_area.remove(applet)
360
# Find the position where to insert the applet
361
child = self.get_first_child()
362
while child:
363
allocation = child.get_allocation()
364
child_x, child_y = self.translate_coordinates(self, 0, 0)
365
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
366
midpoint = child_x + allocation.width / 2
367
if x < midpoint:
368
applet.insert_before(self, child)
369
break
370
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
371
midpoint = child_y + allocation.height / 2
372
if y < midpoint:
373
applet.insert_before(self, child)
374
break
375
child = child.get_next_sibling()
376
else:
377
self.append(applet)
378
applet.show()
379
380
self.get_root().get_application().drags.pop(value)
381
self.get_root().get_application().save_config()
382
383
return True
384
385
def set_edit_mode(self, value):
386
panel: Panel = self.get_root()
387
child = self.get_first_child()
388
while child is not None:
389
if value:
390
child.make_draggable()
391
else:
392
child.restore_drag()
393
child.set_opacity(0.75 if value else 1)
394
child = child.get_next_sibling()
395
396
if value:
397
self.add_controller(self.drop_target)
398
if panel.get_orientation() == Gtk.Orientation.HORIZONTAL:
399
self.set_size_request(48, 0)
400
elif panel.get_orientation() == Gtk.Orientation.VERTICAL:
401
self.set_size_request(0, 48)
402
else:
403
self.remove_controller(self.drop_target)
404
self.set_size_request(0, 0)
405
406
def flash(self):
407
self.add_css_class("panel-flash")
408
409
def unflash(self):
410
self.remove_css_class("panel-flash")
411
412
413
POSITION_TO_LAYER_SHELL_EDGE = {
414
Gtk.PositionType.TOP: Gtk4LayerShell.Edge.TOP,
415
Gtk.PositionType.BOTTOM: Gtk4LayerShell.Edge.BOTTOM,
416
Gtk.PositionType.LEFT: Gtk4LayerShell.Edge.LEFT,
417
Gtk.PositionType.RIGHT: Gtk4LayerShell.Edge.RIGHT,
418
}
419
420
421
class Panel(Gtk.Window):
422
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):
423
super().__init__(application=application)
424
self.drop_motion_controller = None
425
self.motion_controller = None
426
self.set_decorated(False)
427
self.position = None
428
self.autohide = None
429
self.monitor_name = monitor.get_connector()
430
self.hide_time = None
431
self.monitor_index = 0
432
self.can_capture_keyboard = can_capture_keyboard
433
self.open_popovers: set[int] = set()
434
435
self.add_css_class("panorama-panel")
436
437
Gtk4LayerShell.init_for_window(self)
438
Gtk4LayerShell.set_namespace(self, "com.roundabout_host.panorama.panel")
439
Gtk4LayerShell.set_monitor(self, monitor)
440
441
Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP)
442
if can_capture_keyboard:
443
Gtk4LayerShell.set_keyboard_mode(self, Gtk4LayerShell.KeyboardMode.ON_DEMAND)
444
else:
445
Gtk4LayerShell.set_keyboard_mode(self, Gtk4LayerShell.KeyboardMode.NONE)
446
447
box = Gtk.CenterBox()
448
449
self.left_area = AppletArea(orientation=box.get_orientation())
450
self.centre_area = AppletArea(orientation=box.get_orientation())
451
self.right_area = AppletArea(orientation=box.get_orientation())
452
453
box.set_start_widget(self.left_area)
454
box.set_center_widget(self.centre_area)
455
box.set_end_widget(self.right_area)
456
457
self.set_child(box)
458
self.set_position(position)
459
self.set_size(size)
460
self.set_autohide(autohide, hide_time)
461
462
# Add a context menu
463
menu = Gio.Menu()
464
465
menu.append(_("Open _manager"), "panel.manager")
466
menu.append(_("_Applets"), "panel.applets")
467
468
self.context_menu = Gtk.PopoverMenu.new_from_model(menu)
469
self.context_menu.set_has_arrow(False)
470
self.context_menu.set_parent(self)
471
self.context_menu.set_halign(Gtk.Align.START)
472
self.context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
473
panorama_panel.track_popover(self.context_menu)
474
475
right_click_controller = Gtk.GestureClick()
476
right_click_controller.set_button(3)
477
right_click_controller.connect("pressed", self.show_context_menu)
478
479
self.add_controller(right_click_controller)
480
481
action_group = Gio.SimpleActionGroup()
482
manager_action = Gio.SimpleAction.new("manager", None)
483
manager_action.connect("activate", self.show_manager)
484
action_group.add_action(manager_action)
485
applets_action = Gio.SimpleAction.new("applets", None)
486
applets_action.connect("activate", self.show_applets)
487
action_group.add_action(applets_action)
488
self.insert_action_group("panel", action_group)
489
490
def reset_margins(self):
491
for edge in POSITION_TO_LAYER_SHELL_EDGE.values():
492
Gtk4LayerShell.set_margin(self, edge, 0)
493
494
def set_edit_mode(self, value):
495
for area in (self.left_area, self.centre_area, self.right_area):
496
area.set_edit_mode(value)
497
498
def show_context_menu(self, gesture, n_presses, x, y):
499
rect = Gdk.Rectangle()
500
rect.x = int(x)
501
rect.y = int(y)
502
rect.width = 1
503
rect.height = 1
504
505
self.context_menu.set_pointing_to(rect)
506
self.context_menu.popup()
507
508
def slide_in(self):
509
if not self.autohide:
510
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 0)
511
return False
512
513
if Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) >= 0:
514
return False
515
516
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) + 1)
517
return True
518
519
def slide_out(self):
520
if not self.autohide:
521
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 0)
522
return False
523
524
if Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) <= 1 - self.size:
525
return False
526
527
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) - 1)
528
return True
529
530
def show_manager(self, _0=None, _1=None):
531
if self.get_application():
532
if not self.get_application().manager_window:
533
self.get_application().manager_window = PanelManager(self.get_application())
534
self.get_application().manager_window.connect("close-request", self.get_application().reset_manager_window)
535
self.get_application().manager_window.present()
536
537
def show_applets(self, _0=None, _1=None):
538
if self.get_application():
539
if not self.get_application().applets_window:
540
self.get_application().applets_window = AppletListing(self.get_application())
541
self.get_application().applets_window.connect("close-request", self.get_application().reset_applets_window)
542
self.get_application().applets_window.present()
543
544
def get_orientation(self):
545
box = self.get_first_child()
546
return box.get_orientation()
547
548
def set_size(self, value: int):
549
self.size = int(value)
550
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
551
self.set_size_request(800, self.size)
552
self.set_default_size(800, self.size)
553
else:
554
self.set_size_request(self.size, 600)
555
self.set_default_size(self.size, 600)
556
557
def set_position(self, position: Gtk.PositionType):
558
self.position = position
559
self.reset_margins()
560
match self.position:
561
case Gtk.PositionType.TOP:
562
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, False)
563
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
564
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
565
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True)
566
case Gtk.PositionType.BOTTOM:
567
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, False)
568
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
569
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
570
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True)
571
case Gtk.PositionType.LEFT:
572
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, False)
573
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
574
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
575
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
576
case Gtk.PositionType.RIGHT:
577
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, False)
578
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True)
579
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
580
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
581
box = self.get_first_child()
582
match self.position:
583
case Gtk.PositionType.TOP | Gtk.PositionType.BOTTOM:
584
box.set_orientation(Gtk.Orientation.HORIZONTAL)
585
for area in (self.left_area, self.centre_area, self.right_area):
586
area.set_orientation(Gtk.Orientation.HORIZONTAL)
587
case Gtk.PositionType.LEFT | Gtk.PositionType.RIGHT:
588
box.set_orientation(Gtk.Orientation.VERTICAL)
589
for area in (self.left_area, self.centre_area, self.right_area):
590
area.set_orientation(Gtk.Orientation.VERTICAL)
591
592
if self.autohide:
593
if not self.open_popovers:
594
GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_out)
595
596
def set_autohide(self, autohide: bool, hide_time: int):
597
self.autohide = autohide
598
self.hide_time = hide_time
599
if not self.autohide:
600
self.reset_margins()
601
Gtk4LayerShell.auto_exclusive_zone_enable(self)
602
if self.motion_controller is not None:
603
self.remove_controller(self.motion_controller)
604
if self.drop_motion_controller is not None:
605
self.remove_controller(self.drop_motion_controller)
606
self.motion_controller = None
607
self.drop_motion_controller = None
608
self.reset_margins()
609
else:
610
Gtk4LayerShell.set_exclusive_zone(self, 0)
611
# Only leave 1px of the window as a "mouse sensor"
612
Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 1 - self.size)
613
self.motion_controller = Gtk.EventControllerMotion()
614
self.add_controller(self.motion_controller)
615
self.drop_motion_controller = Gtk.DropControllerMotion()
616
self.add_controller(self.drop_motion_controller)
617
618
self.motion_controller.connect("enter", lambda *args: GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_in))
619
self.drop_motion_controller.connect("enter", lambda *args: GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_in))
620
self.motion_controller.connect("leave", lambda *args: not self.open_popovers and GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_out))
621
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))
622
623
def wl_output_enter(self, output, name):
624
for area in (self.left_area, self.centre_area, self.right_area):
625
applet = area.get_first_child()
626
while applet:
627
applet.wl_output_enter(output, name)
628
applet = applet.get_next_sibling()
629
630
def foreign_toplevel_new(self, manager, toplevel):
631
for area in (self.left_area, self.centre_area, self.right_area):
632
applet = area.get_first_child()
633
while applet:
634
applet.foreign_toplevel_new(manager, toplevel)
635
applet = applet.get_next_sibling()
636
637
def foreign_toplevel_output_enter(self, toplevel, output):
638
for area in (self.left_area, self.centre_area, self.right_area):
639
applet = area.get_first_child()
640
while applet:
641
applet.foreign_toplevel_output_enter(toplevel, output)
642
applet = applet.get_next_sibling()
643
644
def foreign_toplevel_output_leave(self, toplevel, output):
645
for area in (self.left_area, self.centre_area, self.right_area):
646
applet = area.get_first_child()
647
while applet:
648
applet.foreign_toplevel_output_leave(toplevel, output)
649
applet = applet.get_next_sibling()
650
651
def foreign_toplevel_app_id(self, toplevel, app_id):
652
for area in (self.left_area, self.centre_area, self.right_area):
653
applet = area.get_first_child()
654
while applet:
655
applet.foreign_toplevel_app_id(toplevel, app_id)
656
applet = applet.get_next_sibling()
657
658
def foreign_toplevel_title(self, toplevel, title):
659
for area in (self.left_area, self.centre_area, self.right_area):
660
applet = area.get_first_child()
661
while applet:
662
applet.foreign_toplevel_title(toplevel, title)
663
applet = applet.get_next_sibling()
664
665
def foreign_toplevel_state(self, toplevel, state):
666
for area in (self.left_area, self.centre_area, self.right_area):
667
applet = area.get_first_child()
668
while applet:
669
applet.foreign_toplevel_state(toplevel, state)
670
applet = applet.get_next_sibling()
671
672
def foreign_toplevel_closed(self, toplevel):
673
for area in (self.left_area, self.centre_area, self.right_area):
674
applet = area.get_first_child()
675
while applet:
676
applet.foreign_toplevel_closed(toplevel)
677
applet = applet.get_next_sibling()
678
679
def foreign_toplevel_refresh(self):
680
for area in (self.left_area, self.centre_area, self.right_area):
681
applet = area.get_first_child()
682
while applet:
683
applet.foreign_toplevel_refresh()
684
applet = applet.get_next_sibling()
685
686
687
def get_all_subclasses(klass: type) -> list[type]:
688
subclasses = []
689
for subclass in klass.__subclasses__():
690
subclasses.append(subclass)
691
subclasses += get_all_subclasses(subclass)
692
693
return subclasses
694
695
def load_packages_from_dir(dir_path: Path):
696
loaded_modules = []
697
698
for path in dir_path.iterdir():
699
if path.name.startswith("_"):
700
continue
701
702
if path.is_dir() and (path / "__init__.py").exists():
703
try:
704
module_name = path.name
705
spec = importlib.util.spec_from_file_location(module_name, path / "__init__.py")
706
module = importlib.util.module_from_spec(spec)
707
module.__package__ = module_name
708
sys.modules[module_name] = module
709
spec.loader.exec_module(module)
710
loaded_modules.append(module)
711
except Exception as e:
712
print(f"Failed to load applet module {path.name}: {e}", file=sys.stderr)
713
traceback.print_exc()
714
else:
715
continue
716
717
return loaded_modules
718
719
720
PANEL_POSITIONS = {
721
"top": Gtk.PositionType.TOP,
722
"bottom": Gtk.PositionType.BOTTOM,
723
"left": Gtk.PositionType.LEFT,
724
"right": Gtk.PositionType.RIGHT,
725
}
726
727
728
PANEL_POSITIONS_REVERSE = {
729
Gtk.PositionType.TOP: "top",
730
Gtk.PositionType.BOTTOM: "bottom",
731
Gtk.PositionType.LEFT: "left",
732
Gtk.PositionType.RIGHT: "right",
733
}
734
735
736
class PanoramaPanel(Gtk.Application):
737
def __init__(self):
738
super().__init__(
739
application_id="com.roundabout_host.roundabout.PanoramaPanel",
740
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE
741
)
742
self.display = Gdk.Display.get_default()
743
self.monitors = self.display.get_monitors()
744
self.applets_by_name: dict[str, panorama_panel.Applet] = {}
745
self.panels: list[Panel] = []
746
self.wl_globals = []
747
self.wl_display = None
748
self.wl_seat = None
749
self.wl_shm = None
750
self.foreign_toplevel_manager = None
751
self.screencopy_manager = None
752
self.manager_window = None
753
self.applets_window = None
754
self.edit_mode = False
755
self.output_proxies = []
756
self.drags = {}
757
self.wl_output_ids = set()
758
self.started = False
759
self.all_handles: set[ZwlrForeignToplevelHandleV1] = set()
760
self.obsolete_handles: set[ZwlrForeignToplevelHandleV1] = set()
761
762
self.add_main_option(
763
"trigger",
764
ord("t"),
765
GLib.OptionFlags.NONE,
766
GLib.OptionArg.STRING,
767
_("Trigger an action which can be processed by the applets"),
768
_("NAME")
769
)
770
771
def destroy_foreign_toplevel_manager(self):
772
self.foreign_toplevel_manager.destroy()
773
self.wl_globals.remove(self.foreign_toplevel_manager)
774
self.foreign_toplevel_manager = None
775
self.foreign_toplevel_refresh()
776
777
def on_foreign_toplevel_manager_finish(self, manager: ZwlrForeignToplevelManagerV1):
778
if self.foreign_toplevel_manager:
779
self.destroy_foreign_toplevel_manager()
780
781
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
782
handle: ZwlrForeignToplevelHandleV1):
783
self.all_handles.add(handle)
784
785
for panel in self.panels:
786
panel.foreign_toplevel_new(manager, handle)
787
handle.dispatcher["title"] = self.on_title_changed
788
handle.dispatcher["app_id"] = self.on_app_id_changed
789
handle.dispatcher["output_enter"] = self.on_output_enter
790
handle.dispatcher["output_leave"] = self.on_output_leave
791
handle.dispatcher["state"] = self.on_state_changed
792
handle.dispatcher["closed"] = self.on_closed
793
794
def on_output_enter(self, handle, output):
795
if handle in self.obsolete_handles:
796
return
797
for panel in self.panels:
798
panel.foreign_toplevel_output_enter(handle, output)
799
800
def on_output_leave(self, handle, output):
801
if handle in self.obsolete_handles:
802
return
803
for panel in self.panels:
804
panel.foreign_toplevel_output_leave(handle, output)
805
806
def on_title_changed(self, handle, title):
807
if handle in self.obsolete_handles:
808
return
809
for panel in self.panels:
810
panel.foreign_toplevel_title(handle, title)
811
812
def on_app_id_changed(self, handle, app_id):
813
if handle in self.obsolete_handles:
814
return
815
for panel in self.panels:
816
panel.foreign_toplevel_app_id(handle, app_id)
817
818
def on_state_changed(self, handle, state):
819
if handle in self.obsolete_handles:
820
return
821
for panel in self.panels:
822
panel.foreign_toplevel_state(handle, state)
823
824
def on_closed(self, handle):
825
if handle in self.obsolete_handles:
826
return
827
for panel in self.panels:
828
panel.foreign_toplevel_closed(handle)
829
830
def foreign_toplevel_refresh(self):
831
self.obsolete_handles = self.all_handles.copy()
832
833
for panel in self.panels:
834
panel.foreign_toplevel_refresh()
835
836
def receive_output_name(self, output: WlOutput, name: str):
837
output.name = name
838
839
def idle_proxy_cover(self, output_name):
840
for monitor in self.display.get_monitors():
841
wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
842
found = False
843
for proxy in self.output_proxies:
844
if proxy._ptr == wl_output:
845
found = True
846
if wl_output and not found:
847
print("Create proxy")
848
output_proxy = WlOutputProxy(wl_output, self.wl_display)
849
output_proxy.interface.registry[output_proxy._ptr] = output_proxy
850
self.output_proxies.append(output_proxy)
851
852
def on_global(self, registry, name, interface, version):
853
if interface == "zwlr_foreign_toplevel_manager_v1":
854
self.foreign_toplevel_manager_id = name
855
self.foreign_toplevel_version = version
856
elif interface == "wl_output":
857
output = registry.bind(name, WlOutput, version)
858
output.id = name
859
self.wl_globals.append(output)
860
print(f"global {name} {interface} {version}")
861
output.global_name = name
862
output.name = None
863
output.dispatcher["name"] = self.receive_output_name
864
while not output.name or not self.foreign_toplevel_version:
865
self.wl_display.dispatch(block=True)
866
print(output.name)
867
self.wl_output_ids.add(output.global_name)
868
if not output.name.startswith("live-preview"):
869
self.generate_panels(output.name)
870
for panel in self.panels:
871
panel.wl_output_enter(output, output.name)
872
if not output.name.startswith("live-preview"):
873
if self.foreign_toplevel_manager:
874
self.foreign_toplevel_manager.stop()
875
self.wl_display.roundtrip()
876
if self.foreign_toplevel_manager:
877
self.destroy_foreign_toplevel_manager()
878
self.foreign_toplevel_manager = self.registry.bind(self.foreign_toplevel_manager_id, ZwlrForeignToplevelManagerV1, self.foreign_toplevel_version)
879
self.foreign_toplevel_manager.id = self.foreign_toplevel_manager_id
880
self.wl_globals.append(self.foreign_toplevel_manager)
881
self.foreign_toplevel_manager.dispatcher["toplevel"] = self.on_new_toplevel
882
self.foreign_toplevel_manager.dispatcher["finished"] = self.on_foreign_toplevel_manager_finish
883
self.wl_display.roundtrip()
884
print(f"Monitor {output.name} ({interface}) connected", file=sys.stderr)
885
elif interface == "wl_seat":
886
self.wl_seat = registry.bind(name, WlSeat, version)
887
self.wl_seat.id = name
888
self.wl_globals.append(self.wl_seat)
889
elif interface == "wl_shm":
890
self.wl_shm = registry.bind(name, WlShm, version)
891
self.wl_shm.id = name
892
self.wl_globals.append(self.wl_shm)
893
elif interface == "zwlr_screencopy_manager_v1":
894
self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version)
895
self.screencopy_manager.id = name
896
self.wl_globals.append(self.screencopy_manager)
897
898
def on_global_remove(self, registry, name):
899
if name in self.wl_output_ids:
900
print(f"Monitor {name} disconnected", file=sys.stderr)
901
self.wl_output_ids.discard(name)
902
for g in self.wl_globals:
903
if g.id == name:
904
g.destroy()
905
self.wl_globals.remove(g)
906
break
907
908
def generate_panels(self, output_name):
909
print("Generating panels...", file=sys.stderr)
910
self.exit_all_applets()
911
912
for i, monitor in enumerate(self.monitors):
913
geometry = monitor.get_geometry()
914
print(f"Monitor {i}: {geometry.width}x{geometry.height} at {geometry.x},{geometry.y}")
915
916
for panel in self.panels:
917
if panel.monitor_name == output_name:
918
panel.destroy()
919
with open(get_config_file(), "r") as config_file:
920
yaml_loader = yaml.YAML(typ="rt")
921
yaml_file = yaml_loader.load(config_file)
922
for panel_data in yaml_file["panels"]:
923
if panel_data["monitor"] != output_name:
924
continue
925
position = PANEL_POSITIONS[panel_data["position"]]
926
my_monitor = None
927
for monitor in self.display.get_monitors():
928
if monitor.get_connector() == panel_data["monitor"]:
929
my_monitor = monitor
930
break
931
if not my_monitor:
932
continue
933
GLib.idle_add(self.idle_proxy_cover, my_monitor.get_connector())
934
size = panel_data["size"]
935
autohide = panel_data["autohide"]
936
hide_time = panel_data["hide_time"]
937
can_capture_keyboard = panel_data["can_capture_keyboard"]
938
939
panel = Panel(self, my_monitor, position, size, autohide, hide_time, can_capture_keyboard)
940
self.panels.append(panel)
941
942
print(f"{size}px panel on {position}, autohide is {autohide} ({hide_time}ms)")
943
944
for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)):
945
applet_list = panel_data["applets"].get(area_name)
946
if applet_list is None:
947
continue
948
949
for applet in applet_list:
950
item = list(applet.items())[0]
951
AppletClass = self.applets_by_name[item[0]]
952
options = item[1]
953
applet_widget = AppletClass(orientation=panel.get_orientation(), config=options)
954
applet_widget.connect("config-changed", self.save_config)
955
applet_widget.set_panel_position(panel.position)
956
957
area.append(applet_widget)
958
959
panel.present()
960
panel.realize()
961
panel.monitor_name = my_monitor.get_connector()
962
963
def do_startup(self):
964
Gtk.Application.do_startup(self)
965
966
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
967
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
968
969
all_applets = list(
970
chain.from_iterable(load_packages_from_dir(d) for d in get_applet_directories()))
971
print("Applets:")
972
subclasses = get_all_subclasses(panorama_panel.Applet)
973
for subclass in subclasses:
974
if subclass.__name__ in self.applets_by_name:
975
print(f"Name conflict for applet {subclass.__name__}. Only one will be loaded.",
976
file=sys.stderr)
977
self.applets_by_name[subclass.__name__] = subclass
978
979
self.wl_display = Display()
980
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
981
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None)))
982
self.wl_display._ptr = wl_display_ptr
983
984
self.foreign_toplevel_manager_id = None
985
self.foreign_toplevel_version = None
986
987
self.idle_proxy_cover(None)
988
self.registry = self.wl_display.get_registry()
989
self.registry.dispatcher["global"] = self.on_global
990
self.registry.dispatcher["global_remove"] = self.on_global_remove
991
self.wl_display.roundtrip()
992
if self.foreign_toplevel_version:
993
print("Foreign Toplevel Interface: Found")
994
else:
995
print("Foreign Toplevel Interface: Not Found")
996
997
self.hold()
998
999
def do_activate(self):
1000
Gio.Application.do_activate(self)
1001
1002
def save_config(self, *args):
1003
print("Saving configuration file")
1004
with open(get_config_file(), "w") as config_file:
1005
yaml_writer = yaml.YAML(typ="rt")
1006
data = {"panels": []}
1007
for panel in self.panels:
1008
panel_data = {
1009
"position": PANEL_POSITIONS_REVERSE[panel.position],
1010
"monitor": panel.monitor_name,
1011
"size": panel.size,
1012
"autohide": panel.autohide,
1013
"hide_time": panel.hide_time,
1014
"can_capture_keyboard": panel.can_capture_keyboard,
1015
"applets": {}
1016
}
1017
1018
for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)):
1019
panel_data["applets"][area_name] = []
1020
applet = area.get_first_child()
1021
while applet is not None:
1022
panel_data["applets"][area_name].append({
1023
applet.__class__.__name__: applet.get_config(),
1024
})
1025
1026
applet = applet.get_next_sibling()
1027
1028
data["panels"].append(panel_data)
1029
1030
yaml_writer.dump(data, config_file)
1031
1032
def exit_all_applets(self):
1033
for panel in self.panels:
1034
for area in (panel.left_area, panel.centre_area, panel.right_area):
1035
applet = area.get_first_child()
1036
while applet is not None:
1037
applet.shutdown(self)
1038
1039
applet = applet.get_next_sibling()
1040
1041
def do_shutdown(self):
1042
print("Shutting down")
1043
self.exit_all_applets()
1044
Gtk.Application.do_shutdown(self)
1045
1046
def do_command_line(self, command_line: Gio.ApplicationCommandLine):
1047
options = command_line.get_options_dict()
1048
args = command_line.get_arguments()[1:]
1049
1050
trigger_variant = options.lookup_value("trigger", GLib.VariantType("s"))
1051
if trigger_variant:
1052
action_name = trigger_variant.get_string()
1053
print(app.list_actions())
1054
self.activate_action(action_name, None)
1055
print(f"Triggered {action_name}")
1056
1057
return 0
1058
1059
def set_edit_mode(self, value):
1060
self.edit_mode = value
1061
for panel in self.panels:
1062
panel.set_edit_mode(value)
1063
1064
def reset_manager_window(self, *args):
1065
self.manager_window = None
1066
1067
def reset_applets_window(self, *args):
1068
self.applets_window = None
1069
1070
1071
if __name__ == "__main__":
1072
app = PanoramaPanel()
1073
app.run(sys.argv)
1074