__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
from pathlib import Path
25
from pywayland.client import Display
26
from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlCompositor
27
from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import (
28
ZwlrForeignToplevelManagerV1,
29
ZwlrForeignToplevelHandleV1
30
)
31
import panorama_panel
32
33
import gi
34
35
gi.require_version("Gtk", "4.0")
36
gi.require_version("GdkWayland", "4.0")
37
38
from gi.repository import Gtk, GLib, Gtk4LayerShell, Gio, Gdk, Pango, GObject
39
40
41
module_directory = Path(__file__).resolve().parent
42
43
locale.bindtextdomain("panorama-window-list", module_directory / "locale")
44
_ = lambda x: locale.dgettext("panorama-window-list", x)
45
46
47
import ctypes
48
from cffi import FFI
49
ffi = FFI()
50
ffi.cdef("""
51
void * gdk_wayland_display_get_wl_display (void * display);
52
void * gdk_wayland_surface_get_wl_surface (void * surface);
53
""")
54
gtk = ffi.dlopen("libgtk-4.so.1")
55
56
57
@Gtk.Template(filename=str(module_directory / "panorama-window-list-options.ui"))
58
class WindowListOptions(Gtk.Window):
59
__gtype_name__ = "WindowListOptions"
60
button_width_adjustment: Gtk.Adjustment = Gtk.Template.Child()
61
62
def __init__(self, **kwargs):
63
super().__init__(**kwargs)
64
65
self.connect("close-request", lambda *args: self.destroy())
66
67
68
def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]:
69
if len(array) % size:
70
raise ValueError(f"The byte string's length must be a multiple of {size}")
71
72
values: list[int] = []
73
for i in range(0, len(array), size):
74
values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder))
75
76
return values
77
78
79
def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]:
80
width = int(widget.get_width())
81
height = int(widget.get_height())
82
83
toplevel = widget.get_root()
84
if not toplevel:
85
return None, None, width, height
86
87
x, y = widget.translate_coordinates(toplevel, 0, 0)
88
x = int(x)
89
y = int(y)
90
91
return x, y, width, height
92
93
94
@dataclasses.dataclass
95
class WindowState:
96
minimised: bool
97
maximised: bool
98
fullscreen: bool
99
focused: bool
100
101
@classmethod
102
def from_state_array(cls, array: bytes):
103
values = split_bytes_into_ints(array)
104
instance = cls(False, False, False, False)
105
for value in values:
106
match value:
107
case 0:
108
instance.maximised = True
109
case 1:
110
instance.minimised = True
111
case 2:
112
instance.focused = True
113
case 3:
114
instance.fullscreen = True
115
116
return instance
117
118
119
from gi.repository import Gtk, Gdk
120
121
class WindowButtonOptions:
122
def __init__(self, max_width: int):
123
self.max_width = max_width
124
125
class WindowButtonLayoutManager(Gtk.LayoutManager):
126
def __init__(self, options: WindowButtonOptions, **kwargs):
127
super().__init__(**kwargs)
128
self.options = options
129
130
def do_measure(self, widget, orientation, for_size):
131
child = widget.get_first_child()
132
if child is None:
133
return 0, 0, 0, 0
134
135
if orientation == Gtk.Orientation.HORIZONTAL:
136
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size)
137
width = min(nat_width, self.options.max_width)
138
return min_width, width, min_height, nat_height
139
else:
140
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size)
141
return min_height, nat_height, 0, 0
142
143
def do_allocate(self, widget, width, height, baseline):
144
child = widget.get_first_child()
145
if child is None:
146
return
147
alloc_width = min(width, self.options.max_width)
148
alloc = Gdk.Rectangle()
149
alloc.x = 0
150
alloc.y = 0
151
alloc.width = alloc_width
152
alloc.height = height
153
child.allocate(alloc.width, alloc.height, baseline)
154
155
156
class WindowButton(Gtk.ToggleButton):
157
def __init__(self, window_id, window_title, **kwargs):
158
super().__init__(**kwargs)
159
160
self.window_id: ZwlrForeignToplevelHandleV1 = window_id
161
self.set_has_frame(False)
162
self.label = Gtk.Label()
163
self.icon = Gtk.Image.new_from_icon_name("application-x-executable")
164
box = Gtk.Box()
165
box.append(self.icon)
166
box.append(self.label)
167
self.set_child(box)
168
169
self.window_title = window_title
170
self.window_state = WindowState(False, False, False, False)
171
172
self.label.set_ellipsize(Pango.EllipsizeMode.END)
173
self.set_hexpand(True)
174
self.set_vexpand(True)
175
176
self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE)
177
self.drag_source.connect("prepare", self.provide_drag_data)
178
self.drag_source.connect("drag-begin", self.drag_begin)
179
self.drag_source.connect("drag-cancel", self.drag_cancel)
180
181
self.add_controller(self.drag_source)
182
183
def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float):
184
app = self.get_root().get_application()
185
app.drags[id(self)] = self
186
value = GObject.Value()
187
value.init(GObject.TYPE_UINT64)
188
value.set_uint64(id(self))
189
return Gdk.ContentProvider.new_for_value(value)
190
191
def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag):
192
paintable = Gtk.WidgetPaintable.new(self).get_current_image()
193
source.set_icon(paintable, 0, 0)
194
self.hide()
195
196
def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason):
197
self.show()
198
return False
199
200
@property
201
def window_title(self):
202
return self.label.get_text()
203
204
@window_title.setter
205
def window_title(self, value):
206
self.label.set_text(value)
207
208
def set_icon_from_app_id(self, app_id):
209
# Try getting an icon from the correct theme
210
app_ids = app_id.split()
211
icon_theme = Gtk.IconTheme.get_for_display(self.get_display())
212
213
for app_id in app_ids:
214
if icon_theme.has_icon(app_id):
215
self.icon.set_from_icon_name(app_id)
216
return
217
218
# If that doesn't work, try getting one from .desktop files
219
for app_id in app_ids:
220
try:
221
desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop")
222
if desktop_file:
223
self.icon.set_from_gicon(desktop_file.get_icon())
224
return
225
except TypeError:
226
# Due to a bug, the constructor may sometimes return C NULL
227
pass
228
229
230
class WFWindowList(panorama_panel.Applet):
231
name = _("Wayfire window list")
232
description = _("Traditional window list (for Wayfire and other wlroots compositors)")
233
234
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
235
super().__init__(orientation=orientation, config=config)
236
if config is None:
237
config = {}
238
239
self.set_homogeneous(True)
240
self.window_button_options = WindowButtonOptions(240)
241
242
self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {}
243
# This button doesn't belong to any window but is used for the button group and to be
244
# selected when no window is focused
245
self.initial_button = Gtk.ToggleButton()
246
247
self.display = None
248
self.wl_surface_ptr = None
249
self.registry = None
250
self.compositor = None
251
self.seat = None
252
253
self.context_menu = self.make_context_menu()
254
panorama_panel.track_popover(self.context_menu)
255
256
right_click_controller = Gtk.GestureClick()
257
right_click_controller.set_button(3)
258
right_click_controller.connect("pressed", self.show_context_menu)
259
260
self.add_controller(right_click_controller)
261
262
action_group = Gio.SimpleActionGroup()
263
options_action = Gio.SimpleAction.new("options", None)
264
options_action.connect("activate", self.show_options)
265
action_group.add_action(options_action)
266
self.insert_action_group("applet", action_group)
267
# Wait for the widget to be in a layer-shell window before doing this
268
self.connect("realize", lambda *args: self.get_wl_resources())
269
270
self.options_window = None
271
272
# Support button reordering
273
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
274
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
275
self.drop_target.connect("drop", self.drop_button)
276
277
self.add_controller(self.drop_target)
278
279
def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
280
button: WindowButton = self.get_root().get_application().drags.pop(value)
281
if button.get_parent() is not self:
282
# Prevent dropping a button from another window list
283
return False
284
285
self.remove(button)
286
# Find the position where to insert the applet
287
# Probably we could use the assumption that buttons are homogeneous here for efficiency
288
child = self.get_first_child()
289
while child:
290
allocation = child.get_allocation()
291
child_x, child_y = self.translate_coordinates(self, 0, 0)
292
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
293
midpoint = child_x + allocation.width / 2
294
if x < midpoint:
295
button.insert_before(self, child)
296
break
297
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
298
midpoint = child_y + allocation.height / 2
299
if y < midpoint:
300
button.insert_before(self, child)
301
break
302
child = child.get_next_sibling()
303
else:
304
self.append(button)
305
button.show()
306
307
self.set_all_rectangles()
308
return True
309
310
def get_wl_resources(self):
311
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
312
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
313
314
self.display = Display()
315
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
316
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None)))
317
self.display._ptr = wl_display_ptr
318
319
# Intentionally commented: the display is already connected by GTK
320
# self.display.connect()
321
322
self.registry = self.display.get_registry()
323
self.registry.dispatcher["global"] = self.on_global
324
self.display.roundtrip()
325
fd = self.display.get_fd()
326
GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event)
327
328
def on_display_event(self, source, condition):
329
if condition == GLib.IO_IN:
330
self.display.dispatch(block=True)
331
return True
332
333
def on_global(self, registry, name, interface, version):
334
if interface == "zwlr_foreign_toplevel_manager_v1":
335
self.print_log("Interface registered")
336
self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version)
337
self.manager.dispatcher["toplevel"] = self.on_new_toplevel
338
self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
339
self.display.roundtrip()
340
self.display.flush()
341
elif interface == "wl_seat":
342
self.print_log("Seat found")
343
self.seat = registry.bind(name, WlSeat, version)
344
elif interface == "wl_compositor":
345
self.compositor = registry.bind(name, WlCompositor, version)
346
self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
347
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
348
self.get_root().get_native().get_surface().__gpointer__, None)))
349
350
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
351
handle: ZwlrForeignToplevelHandleV1):
352
handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title)
353
handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id)
354
handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states)
355
handle.dispatcher["closed"] = lambda h: self.on_closed(h)
356
357
def on_title_changed(self, handle, title):
358
if handle not in self.toplevel_buttons:
359
button = WindowButton(handle, title)
360
button.set_group(self.initial_button)
361
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options))
362
button.connect("clicked", self.on_button_click)
363
self.toplevel_buttons[handle] = button
364
self.append(button)
365
else:
366
button = self.toplevel_buttons[handle]
367
button.window_title = title
368
369
self.set_all_rectangles()
370
371
def set_all_rectangles(self):
372
for button in self.toplevel_buttons.values():
373
surface = WlSurface()
374
surface._ptr = self.wl_surface_ptr
375
button.window_id.set_rectangle(surface, *get_widget_rect(button))
376
377
def on_button_click(self, button: WindowButton):
378
# Set a rectangle for animation
379
surface = WlSurface()
380
surface._ptr = self.wl_surface_ptr
381
button.window_id.set_rectangle(surface, *get_widget_rect(button))
382
if button.window_state.focused:
383
# Already pressed in, so minimise the focused window
384
button.window_id.set_minimized()
385
else:
386
button.window_id.unset_minimized()
387
button.window_id.activate(self.seat)
388
389
self.display.flush()
390
391
def on_state_changed(self, handle, states):
392
if handle in self.toplevel_buttons:
393
state_info = WindowState.from_state_array(states)
394
button = self.toplevel_buttons[handle]
395
button.window_state = state_info
396
if state_info.focused:
397
button.set_active(True)
398
else:
399
self.initial_button.set_active(True)
400
401
self.set_all_rectangles()
402
403
def on_app_id_changed(self, handle, app_id):
404
if handle in self.toplevel_buttons:
405
button = self.toplevel_buttons[handle]
406
button.set_icon_from_app_id(app_id)
407
408
def on_closed(self, handle):
409
if handle in self.toplevel_buttons:
410
self.remove(self.toplevel_buttons[handle])
411
self.toplevel_buttons.pop(handle)
412
413
self.set_all_rectangles()
414
415
def make_context_menu(self):
416
menu = Gio.Menu()
417
menu.append(_("Window list _options"), "applet.options")
418
context_menu = Gtk.PopoverMenu.new_from_model(menu)
419
context_menu.set_has_arrow(False)
420
context_menu.set_parent(self)
421
context_menu.set_halign(Gtk.Align.START)
422
context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
423
return context_menu
424
425
def show_context_menu(self, gesture, n_presses, x, y):
426
rect = Gdk.Rectangle()
427
rect.x = int(x)
428
rect.y = int(y)
429
rect.width = 1
430
rect.height = 1
431
432
self.context_menu.set_pointing_to(rect)
433
self.context_menu.popup()
434
435
def show_options(self, _0=None, _1=None):
436
if self.options_window is None:
437
self.options_window = WindowListOptions()
438
self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
439
self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
440
441
def reset_window(*args):
442
self.options_window = None
443
444
self.options_window.connect("close-request", reset_window)
445
self.options_window.present()
446
447
def update_button_options(self, adjustment):
448
self.window_button_options.max_width = adjustment.get_value()
449
child: Gtk.Widget = self.get_first_child()
450
while child:
451
child.queue_allocate()
452
child.queue_resize()
453
child.queue_draw()
454
child = child.get_next_sibling()
455
456
def get_config(self):
457
return {}