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

 __init__.py

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