__init__.py
Python script, ASCII text executable
1
"""
2
Traditional window list applet for the Panorama panel, compatible
3
with wlroots-based compositors.
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
import dataclasses
21
import os
22
import sys
23
import locale
24
import typing
25
from pathlib import Path
26
from pywayland.client import Display, EventQueue
27
from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlCompositor, WlOutput
28
from pywayland.protocol.wayland.wl_output import WlOutputProxy
29
from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import (
30
ZwlrForeignToplevelManagerV1,
31
ZwlrForeignToplevelHandleV1
32
)
33
import panorama_panel
34
35
import gi
36
37
gi.require_version("Gtk", "4.0")
38
gi.require_version("GdkWayland", "4.0")
39
40
from gi.repository import Gtk, GLib, Gtk4LayerShell, Gio, Gdk, Pango, GObject
41
42
43
module_directory = Path(__file__).resolve().parent
44
45
locale.bindtextdomain("panorama-window-list", module_directory / "locale")
46
_ = lambda x: locale.dgettext("panorama-window-list", x)
47
48
49
import ctypes
50
from cffi import FFI
51
ffi = FFI()
52
ffi.cdef("""
53
void * gdk_wayland_display_get_wl_display (void * display);
54
void * gdk_wayland_surface_get_wl_surface (void * surface);
55
void * gdk_wayland_monitor_get_wl_output (void * monitor);
56
""")
57
gtk = ffi.dlopen("libgtk-4.so.1")
58
59
60
@Gtk.Template(filename=str(module_directory / "panorama-window-list-options.ui"))
61
class WindowListOptions(Gtk.Window):
62
__gtype_name__ = "WindowListOptions"
63
button_width_adjustment: Gtk.Adjustment = Gtk.Template.Child()
64
65
def __init__(self, **kwargs):
66
super().__init__(**kwargs)
67
68
self.connect("close-request", lambda *args: self.destroy())
69
70
71
def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]:
72
if len(array) % size:
73
raise ValueError(f"The byte string's length must be a multiple of {size}")
74
75
values: list[int] = []
76
for i in range(0, len(array), size):
77
values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder))
78
79
return values
80
81
82
def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]:
83
width = int(widget.get_width())
84
height = int(widget.get_height())
85
86
toplevel = widget.get_root()
87
if not toplevel:
88
return None, None, width, height
89
90
x, y = widget.translate_coordinates(toplevel, 0, 0)
91
x = int(x)
92
y = int(y)
93
94
return x, y, width, height
95
96
97
@dataclasses.dataclass
98
class WindowState:
99
minimised: bool
100
maximised: bool
101
fullscreen: bool
102
focused: bool
103
104
@classmethod
105
def from_state_array(cls, array: bytes):
106
values = split_bytes_into_ints(array)
107
instance = cls(False, False, False, False)
108
for value in values:
109
match value:
110
case 0:
111
instance.maximised = True
112
case 1:
113
instance.minimised = True
114
case 2:
115
instance.focused = True
116
case 3:
117
instance.fullscreen = True
118
119
return instance
120
121
122
from gi.repository import Gtk, Gdk
123
124
class WindowButtonOptions:
125
def __init__(self, max_width: int):
126
self.max_width = max_width
127
128
class WindowButtonLayoutManager(Gtk.LayoutManager):
129
def __init__(self, options: WindowButtonOptions, **kwargs):
130
super().__init__(**kwargs)
131
self.options = options
132
133
def do_measure(self, widget, orientation, for_size):
134
child = widget.get_first_child()
135
if child is None:
136
return 0, 0, 0, 0
137
138
if orientation == Gtk.Orientation.HORIZONTAL:
139
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size)
140
width = self.options.max_width
141
return min_width, width, min_height, nat_height
142
else:
143
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size)
144
return min_height, nat_height, 0, 0
145
146
def do_allocate(self, widget, width, height, baseline):
147
child = widget.get_first_child()
148
if child is None:
149
return
150
alloc_width = min(width, self.options.max_width)
151
alloc = Gdk.Rectangle()
152
alloc.x = 0
153
alloc.y = 0
154
alloc.width = alloc_width
155
alloc.height = height
156
child.allocate(alloc.width, alloc.height, baseline)
157
158
159
class WindowButton(Gtk.ToggleButton):
160
def __init__(self, window_id, window_title, **kwargs):
161
super().__init__(**kwargs)
162
163
self.window_id: ZwlrForeignToplevelHandleV1 = window_id
164
self.wf_ipc_id: typing.Optional[int] = None
165
self.set_has_frame(False)
166
self.label = Gtk.Label()
167
self.icon = Gtk.Image.new_from_icon_name("application-x-executable")
168
box = Gtk.Box()
169
box.append(self.icon)
170
box.append(self.label)
171
self.set_child(box)
172
173
self.window_title = window_title
174
self.window_state = WindowState(False, False, False, False)
175
176
self.label.set_ellipsize(Pango.EllipsizeMode.END)
177
self.set_hexpand(True)
178
self.set_vexpand(True)
179
180
self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE)
181
self.drag_source.connect("prepare", self.provide_drag_data)
182
self.drag_source.connect("drag-begin", self.drag_begin)
183
self.drag_source.connect("drag-cancel", self.drag_cancel)
184
185
self.add_controller(self.drag_source)
186
187
def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float):
188
app = self.get_root().get_application()
189
app.drags[id(self)] = self
190
value = GObject.Value()
191
value.init(GObject.TYPE_UINT64)
192
value.set_uint64(id(self))
193
return Gdk.ContentProvider.new_for_value(value)
194
195
def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag):
196
paintable = Gtk.WidgetPaintable.new(self).get_current_image()
197
source.set_icon(paintable, 0, 0)
198
self.hide()
199
200
def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason):
201
self.show()
202
return False
203
204
@property
205
def window_title(self):
206
return self.label.get_text()
207
208
@window_title.setter
209
def window_title(self, value):
210
self.label.set_text(value)
211
212
def set_icon_from_app_id(self, app_id):
213
app_ids = app_id.split()
214
215
# If on Wayfire, find the IPC ID
216
for app_id in app_ids:
217
if app_id.startswith("wf-ipc-"):
218
self.wf_ipc_id = int(app_id.removeprefix("wf-ipc-"))
219
break
220
221
# Try getting an icon from the correct theme
222
icon_theme = Gtk.IconTheme.get_for_display(self.get_display())
223
224
for app_id in app_ids:
225
if icon_theme.has_icon(app_id):
226
self.icon.set_from_icon_name(app_id)
227
return
228
229
# If that doesn't work, try getting one from .desktop files
230
for app_id in app_ids:
231
try:
232
desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop")
233
if desktop_file:
234
self.icon.set_from_gicon(desktop_file.get_icon())
235
return
236
except TypeError:
237
# Due to a bug, the constructor may sometimes return C NULL
238
pass
239
240
241
class WFWindowList(panorama_panel.Applet):
242
name = _("Wayfire window list")
243
description = _("Traditional window list (for Wayfire and other wlroots compositors)")
244
245
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
246
super().__init__(orientation=orientation, config=config)
247
if config is None:
248
config = {}
249
250
self.set_homogeneous(True)
251
self.window_button_options = WindowButtonOptions(config.get("max_button_width", 256))
252
253
self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {}
254
self.toplevel_buttons_by_wf_id: dict[int, WindowButton] = {}
255
# This button doesn't belong to any window but is used for the button group and to be
256
# selected when no window is focused
257
self.initial_button = Gtk.ToggleButton()
258
259
self.display = None
260
self.my_output = None
261
self.wl_surface_ptr = None
262
self.registry = None
263
self.compositor = None
264
self.seat = None
265
266
self.context_menu = self.make_context_menu()
267
panorama_panel.track_popover(self.context_menu)
268
269
right_click_controller = Gtk.GestureClick()
270
right_click_controller.set_button(3)
271
right_click_controller.connect("pressed", self.show_context_menu)
272
273
self.add_controller(right_click_controller)
274
275
action_group = Gio.SimpleActionGroup()
276
options_action = Gio.SimpleAction.new("options", None)
277
options_action.connect("activate", self.show_options)
278
action_group.add_action(options_action)
279
self.insert_action_group("applet", action_group)
280
# Wait for the widget to be in a layer-shell window before doing this
281
self.connect("realize", lambda *args: self.get_wl_resources())
282
283
self.options_window = None
284
285
# Support button reordering
286
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
287
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
288
self.drop_target.connect("drop", self.drop_button)
289
290
self.add_controller(self.drop_target)
291
292
# Make a Wayfire socket for workspace handling
293
try:
294
import wayfire
295
self.wf_socket = wayfire.WayfireSocket()
296
self.wf_socket.watch()
297
fd = self.wf_socket.client.fileno()
298
GLib.io_add_watch(GLib.IOChannel.unix_new(fd), GLib.IO_IN, self.on_wf_event, priority=GLib.PRIORITY_HIGH)
299
except:
300
# Wayfire raises Exception itself, so it cannot be narrowed down
301
self.wf_socket = None
302
303
def on_wf_event(self, source, condition):
304
if condition & GLib.IO_IN:
305
try:
306
message = self.wf_socket.read_next_event()
307
event = message.get("event")
308
match event:
309
case "view-workspace-changed":
310
view = message.get("view", {})
311
output = self.wf_socket.get_output(self.get_root().monitor_index + 1)
312
current_workspace = output["workspace"]["x"], output["workspace"]["y"]
313
if (message["to"]["x"], message["to"]["y"]) == current_workspace:
314
if self.toplevel_buttons_by_wf_id[view["id"]].get_parent() is None:
315
self.append(self.toplevel_buttons_by_wf_id[view["id"]])
316
else:
317
if self.toplevel_buttons_by_wf_id[view["id"]].get_parent() is self:
318
# Remove out-of-workspace window
319
self.remove(self.toplevel_buttons_by_wf_id[view["id"]])
320
case "wset-workspace-changed":
321
output_id = self.get_root().monitor_index + 1
322
if message["wset-data"]["output-id"] == output_id:
323
# It has changed on this monitor; refresh the window list
324
self.filter_to_wf_workspace()
325
326
except Exception as e:
327
print("Error reading Wayfire event:", e)
328
return True
329
330
def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
331
button: WindowButton = self.get_root().get_application().drags.pop(value)
332
if button.get_parent() is not self:
333
# Prevent dropping a button from another window list
334
return False
335
336
self.remove(button)
337
# Find the position where to insert the applet
338
# Probably we could use the assumption that buttons are homogeneous here for efficiency
339
child = self.get_first_child()
340
while child:
341
allocation = child.get_allocation()
342
child_x, child_y = self.translate_coordinates(self, 0, 0)
343
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
344
midpoint = child_x + allocation.width / 2
345
if x < midpoint:
346
button.insert_before(self, child)
347
break
348
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
349
midpoint = child_y + allocation.height / 2
350
if y < midpoint:
351
button.insert_before(self, child)
352
break
353
child = child.get_next_sibling()
354
else:
355
self.append(button)
356
button.show()
357
358
self.set_all_rectangles()
359
return True
360
361
def filter_to_wf_workspace(self):
362
output = self.wf_socket.get_output(self.get_root().monitor_index + 1)
363
for wf_id, button in self.toplevel_buttons_by_wf_id.items():
364
view = self.wf_socket.get_view(wf_id)
365
mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
366
mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
367
output_width = output["geometry"]["width"]
368
output_height = output["geometry"]["height"]
369
if 0 <= mid_x < output_width and 0 <= mid_y < output_height:
370
# It is in this workspace; keep it
371
if button.get_parent() is None:
372
self.append(button)
373
else:
374
# Remove it from this window list
375
if button.get_parent() is self:
376
self.remove(button)
377
378
def get_wl_resources(self):
379
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
380
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
381
382
self.display = Display()
383
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
384
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None)))
385
self.display._ptr = wl_display_ptr
386
self.event_queue = EventQueue(self.display)
387
388
# Intentionally commented: the display is already connected by GTK
389
# self.display.connect()
390
391
my_monitor = Gtk4LayerShell.get_monitor(self.get_root())
392
393
# Iterate through monitors and get their Wayland output (wl_output)
394
# This is a hack to ensure output_enter/leave is called for toplevels
395
for monitor in self.get_root().get_native().get_display().get_monitors():
396
wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
397
if wl_output:
398
print("Create proxy")
399
output_proxy = WlOutputProxy(wl_output, self.display)
400
output_proxy.interface.registry[output_proxy._ptr] = output_proxy
401
402
if monitor == my_monitor:
403
self.my_output = output_proxy
404
# End hack
405
406
self.registry = self.display.get_registry()
407
self.registry.dispatcher["global"] = self.on_global
408
self.display.roundtrip()
409
fd = self.display.get_fd()
410
GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event)
411
412
if self.wf_socket is not None:
413
self.filter_to_wf_workspace()
414
415
def on_display_event(self, source, condition):
416
if condition & GLib.IO_IN:
417
self.display.dispatch(queue=self.event_queue)
418
return True
419
420
def on_global(self, registry, name, interface, version):
421
if interface == "zwlr_foreign_toplevel_manager_v1":
422
self.print_log("Interface registered")
423
self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version)
424
self.manager.dispatcher["toplevel"] = self.on_new_toplevel
425
self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
426
self.display.roundtrip()
427
self.display.flush()
428
elif interface == "wl_seat":
429
self.print_log("Seat found")
430
self.seat = registry.bind(name, WlSeat, version)
431
elif interface == "wl_compositor":
432
self.compositor = registry.bind(name, WlCompositor, version)
433
self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
434
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
435
self.get_root().get_native().get_surface().__gpointer__, None)))
436
437
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
438
handle: ZwlrForeignToplevelHandleV1):
439
handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title)
440
handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id)
441
handle.dispatcher["output_enter"] = self.on_output_entered
442
handle.dispatcher["output_leave"] = self.on_output_left
443
handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states)
444
handle.dispatcher["closed"] = lambda h: self.on_closed(h)
445
446
def on_output_entered(self, handle, output):
447
# TODO: make this configurable
448
# TODO: on wayfire, append/remove buttons when the workspace changes
449
if output != self.my_output:
450
return
451
if handle in self.toplevel_buttons:
452
button = self.toplevel_buttons[handle]
453
self.append(button)
454
self.set_all_rectangles()
455
456
def on_output_left(self, handle, output):
457
if output != self.my_output:
458
return
459
if handle in self.toplevel_buttons:
460
button = self.toplevel_buttons[handle]
461
self.remove(button)
462
self.set_all_rectangles()
463
464
def on_title_changed(self, handle, title):
465
if handle not in self.toplevel_buttons:
466
button = WindowButton(handle, title)
467
button.set_group(self.initial_button)
468
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options))
469
button.connect("clicked", self.on_button_click)
470
self.toplevel_buttons[handle] = button
471
else:
472
button = self.toplevel_buttons[handle]
473
button.window_title = title
474
475
def set_all_rectangles(self):
476
child = self.get_first_child()
477
while child is not None:
478
if isinstance(child, WindowButton):
479
surface = WlSurface()
480
surface._ptr = self.wl_surface_ptr
481
child.window_id.set_rectangle(surface, *get_widget_rect(child))
482
483
child = child.get_next_sibling()
484
485
def on_button_click(self, button: WindowButton):
486
# Set a rectangle for animation
487
surface = WlSurface()
488
surface._ptr = self.wl_surface_ptr
489
button.window_id.set_rectangle(surface, *get_widget_rect(button))
490
if button.window_state.focused:
491
# Already pressed in, so minimise the focused window
492
button.window_id.set_minimized()
493
else:
494
button.window_id.unset_minimized()
495
button.window_id.activate(self.seat)
496
497
self.display.flush()
498
499
def on_state_changed(self, handle, states):
500
if handle in self.toplevel_buttons:
501
state_info = WindowState.from_state_array(states)
502
button = self.toplevel_buttons[handle]
503
button.window_state = state_info
504
if state_info.focused:
505
button.set_active(True)
506
else:
507
self.initial_button.set_active(True)
508
509
self.set_all_rectangles()
510
511
def on_app_id_changed(self, handle, app_id):
512
if handle in self.toplevel_buttons:
513
button = self.toplevel_buttons[handle]
514
button.set_icon_from_app_id(app_id)
515
app_ids = app_id.split()
516
for app_id in app_ids:
517
if app_id.startswith("wf-ipc-"):
518
self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button
519
520
def on_closed(self, handle):
521
button: WindowButton = self.toplevel_buttons[handle]
522
wf_id = button.wf_ipc_id
523
if handle in self.toplevel_buttons:
524
self.remove(self.toplevel_buttons[handle])
525
self.toplevel_buttons.pop(handle)
526
if wf_id in self.toplevel_buttons_by_wf_id:
527
self.toplevel_buttons_by_wf_id.pop(wf_id)
528
529
self.set_all_rectangles()
530
531
def make_context_menu(self):
532
menu = Gio.Menu()
533
menu.append(_("Window list _options"), "applet.options")
534
context_menu = Gtk.PopoverMenu.new_from_model(menu)
535
context_menu.set_has_arrow(False)
536
context_menu.set_parent(self)
537
context_menu.set_halign(Gtk.Align.START)
538
context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
539
return context_menu
540
541
def show_context_menu(self, gesture, n_presses, x, y):
542
rect = Gdk.Rectangle()
543
rect.x = int(x)
544
rect.y = int(y)
545
rect.width = 1
546
rect.height = 1
547
548
self.context_menu.set_pointing_to(rect)
549
self.context_menu.popup()
550
551
def show_options(self, _0=None, _1=None):
552
if self.options_window is None:
553
self.options_window = WindowListOptions()
554
self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
555
self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
556
557
def reset_window(*args):
558
self.options_window = None
559
560
self.options_window.connect("close-request", reset_window)
561
self.options_window.present()
562
563
def update_button_options(self, adjustment):
564
self.window_button_options.max_width = adjustment.get_value()
565
child: Gtk.Widget = self.get_first_child()
566
while child:
567
child.queue_allocate()
568
child.queue_resize()
569
child.queue_draw()
570
child = child.get_next_sibling()
571
572
def get_config(self):
573
return {"max_button_width": self.window_button_options.max_width}
574
575
def output_changed(self):
576
self.get_wl_resources()
577