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