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