By using this site, you agree to have cookies stored on your device, strictly for functional purposes, such as storing your session and preferences.

Dismiss

 main.py

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