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 • 21.71 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
import typing
25
from pathlib import Path
26
from pywayland.client import Display
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 = min(nat_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(fd, GLib.IO_IN, self.on_wf_event)
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
self.append(self.toplevel_buttons_by_wf_id[view["id"]])
315
else:
316
# Remove out-of-workspace window
317
self.remove(self.toplevel_buttons_by_wf_id[view["id"]])
318
case "wset-workspace-changed":
319
output_id = self.get_root().monitor_index + 1
320
if message["wset-data"]["output-id"] == output_id:
321
# It has changed on this monitor; refresh the window list
322
self.filter_to_wf_workspace()
323
324
except Exception as e:
325
print("Error reading Wayfire event:", e)
326
return True
327
328
def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
329
button: WindowButton = self.get_root().get_application().drags.pop(value)
330
if button.get_parent() is not self:
331
# Prevent dropping a button from another window list
332
return False
333
334
self.remove(button)
335
# Find the position where to insert the applet
336
# Probably we could use the assumption that buttons are homogeneous here for efficiency
337
child = self.get_first_child()
338
while child:
339
allocation = child.get_allocation()
340
child_x, child_y = self.translate_coordinates(self, 0, 0)
341
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
342
midpoint = child_x + allocation.width / 2
343
if x < midpoint:
344
button.insert_before(self, child)
345
break
346
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
347
midpoint = child_y + allocation.height / 2
348
if y < midpoint:
349
button.insert_before(self, child)
350
break
351
child = child.get_next_sibling()
352
else:
353
self.append(button)
354
button.show()
355
356
self.set_all_rectangles()
357
return True
358
359
def filter_to_wf_workspace(self):
360
output = self.wf_socket.get_output(self.get_root().monitor_index + 1)
361
for wf_id, button in self.toplevel_buttons_by_wf_id.items():
362
view = self.wf_socket.get_view(wf_id)
363
mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
364
mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
365
output_width = output["geometry"]["width"]
366
output_height = output["geometry"]["height"]
367
if 0 <= mid_x < output_width and 0 <= mid_y < output_height:
368
# It is in this workspace; keep it
369
if not button.get_realized():
370
self.append(button)
371
else:
372
# Remove it from this window list
373
if button.get_parent() is self:
374
self.remove(button)
375
376
def get_wl_resources(self):
377
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
378
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
379
380
self.display = Display()
381
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
382
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None)))
383
self.display._ptr = wl_display_ptr
384
385
# Intentionally commented: the display is already connected by GTK
386
# self.display.connect()
387
388
my_monitor = Gtk4LayerShell.get_monitor(self.get_root())
389
390
# Iterate through monitors and get their Wayland output (wl_output)
391
# This is a hack to ensure output_enter/leave is called for toplevels
392
for monitor in self.get_root().get_native().get_display().get_monitors():
393
wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
394
if wl_output:
395
print("Create proxy")
396
output_proxy = WlOutputProxy(wl_output, self.display)
397
output_proxy.interface.registry[output_proxy._ptr] = output_proxy
398
399
if monitor == my_monitor:
400
self.my_output = output_proxy
401
# End hack
402
403
self.registry = self.display.get_registry()
404
self.registry.dispatcher["global"] = self.on_global
405
self.display.roundtrip()
406
fd = self.display.get_fd()
407
GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event)
408
409
if self.wf_socket is not None:
410
self.filter_to_wf_workspace()
411
412
def on_display_event(self, source, condition):
413
if condition == GLib.IO_IN:
414
self.display.dispatch(block=True)
415
return True
416
417
def on_global(self, registry, name, interface, version):
418
if interface == "zwlr_foreign_toplevel_manager_v1":
419
self.print_log("Interface registered")
420
self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version)
421
self.manager.dispatcher["toplevel"] = self.on_new_toplevel
422
self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
423
self.display.roundtrip()
424
self.display.flush()
425
elif interface == "wl_seat":
426
self.print_log("Seat found")
427
self.seat = registry.bind(name, WlSeat, version)
428
elif interface == "wl_compositor":
429
self.compositor = registry.bind(name, WlCompositor, version)
430
self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
431
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
432
self.get_root().get_native().get_surface().__gpointer__, None)))
433
434
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
435
handle: ZwlrForeignToplevelHandleV1):
436
handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title)
437
handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id)
438
handle.dispatcher["output_enter"] = self.on_output_entered
439
handle.dispatcher["output_leave"] = self.on_output_left
440
handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states)
441
handle.dispatcher["closed"] = lambda h: self.on_closed(h)
442
443
def on_output_entered(self, handle, output):
444
# TODO: make this configurable
445
# TODO: on wayfire, append/remove buttons when the workspace changes
446
if output != self.my_output:
447
return
448
if handle in self.toplevel_buttons:
449
button = self.toplevel_buttons[handle]
450
self.append(button)
451
self.set_all_rectangles()
452
453
def on_output_left(self, handle, output):
454
if output != self.my_output:
455
return
456
if handle in self.toplevel_buttons:
457
button = self.toplevel_buttons[handle]
458
self.remove(button)
459
self.set_all_rectangles()
460
461
def on_title_changed(self, handle, title):
462
if handle not in self.toplevel_buttons:
463
button = WindowButton(handle, title)
464
button.set_group(self.initial_button)
465
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options))
466
button.connect("clicked", self.on_button_click)
467
self.toplevel_buttons[handle] = button
468
else:
469
button = self.toplevel_buttons[handle]
470
button.window_title = title
471
472
def set_all_rectangles(self):
473
child = self.get_first_child()
474
while child is not None:
475
if isinstance(child, WindowButton):
476
surface = WlSurface()
477
surface._ptr = self.wl_surface_ptr
478
child.window_id.set_rectangle(surface, *get_widget_rect(child))
479
480
child = child.get_next_sibling()
481
482
def on_button_click(self, button: WindowButton):
483
# Set a rectangle for animation
484
surface = WlSurface()
485
surface._ptr = self.wl_surface_ptr
486
button.window_id.set_rectangle(surface, *get_widget_rect(button))
487
if button.window_state.focused:
488
# Already pressed in, so minimise the focused window
489
button.window_id.set_minimized()
490
else:
491
button.window_id.unset_minimized()
492
button.window_id.activate(self.seat)
493
494
self.display.flush()
495
496
def on_state_changed(self, handle, states):
497
if handle in self.toplevel_buttons:
498
state_info = WindowState.from_state_array(states)
499
button = self.toplevel_buttons[handle]
500
button.window_state = state_info
501
if state_info.focused:
502
button.set_active(True)
503
else:
504
self.initial_button.set_active(True)
505
506
self.set_all_rectangles()
507
508
def on_app_id_changed(self, handle, app_id):
509
if handle in self.toplevel_buttons:
510
button = self.toplevel_buttons[handle]
511
button.set_icon_from_app_id(app_id)
512
app_ids = app_id.split()
513
for app_id in app_ids:
514
if app_id.startswith("wf-ipc-"):
515
self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button
516
517
def on_closed(self, handle):
518
if handle in self.toplevel_buttons:
519
self.remove(self.toplevel_buttons[handle])
520
self.toplevel_buttons.pop(handle)
521
if handle in self.toplevel_buttons_by_wf_id:
522
self.toplevel_buttons_by_wf_id.pop(handle)
523
524
self.set_all_rectangles()
525
526
def make_context_menu(self):
527
menu = Gio.Menu()
528
menu.append(_("Window list _options"), "applet.options")
529
context_menu = Gtk.PopoverMenu.new_from_model(menu)
530
context_menu.set_has_arrow(False)
531
context_menu.set_parent(self)
532
context_menu.set_halign(Gtk.Align.START)
533
context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
534
return context_menu
535
536
def show_context_menu(self, gesture, n_presses, x, y):
537
rect = Gdk.Rectangle()
538
rect.x = int(x)
539
rect.y = int(y)
540
rect.width = 1
541
rect.height = 1
542
543
self.context_menu.set_pointing_to(rect)
544
self.context_menu.popup()
545
546
def show_options(self, _0=None, _1=None):
547
if self.options_window is None:
548
self.options_window = WindowListOptions()
549
self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
550
self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
551
552
def reset_window(*args):
553
self.options_window = None
554
555
self.options_window.connect("close-request", reset_window)
556
self.options_window.present()
557
558
def update_button_options(self, adjustment):
559
self.window_button_options.max_width = adjustment.get_value()
560
child: Gtk.Widget = self.get_first_child()
561
while child:
562
child.queue_allocate()
563
child.queue_resize()
564
child.queue_draw()
565
child = child.get_next_sibling()
566
567
def get_config(self):
568
return {"max_button_width": self.window_button_options.max_width}
569
570
def output_changed(self):
571
self.get_wl_resources()
572