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.21 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(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
390
# Intentionally commented: the display is already connected by GTK
391
# self.display.connect()
392
393
my_monitor = Gtk4LayerShell.get_monitor(self.get_root())
394
395
# Iterate through monitors and get their Wayland output (wl_output)
396
# This is a hack to ensure output_enter/leave is called for toplevels
397
for monitor in self.get_root().get_native().get_display().get_monitors():
398
wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
399
if wl_output:
400
print("Create proxy")
401
output_proxy = WlOutputProxy(wl_output, self.display)
402
output_proxy.interface.registry[output_proxy._ptr] = output_proxy
403
404
if monitor == my_monitor:
405
self.my_output = output_proxy
406
# End hack
407
408
self.registry = self.display.get_registry()
409
self.registry.dispatcher["global"] = self.on_global
410
self.display.roundtrip()
411
fd = self.display.get_fd()
412
GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event)
413
414
if self.wf_socket is not None:
415
self.filter_to_wf_workspace()
416
417
def on_display_event(self, source, condition):
418
if condition == GLib.IO_IN:
419
self.display.dispatch(block=True)
420
return True
421
422
def on_global(self, registry, name, interface, version):
423
if interface == "zwlr_foreign_toplevel_manager_v1":
424
self.print_log("Interface registered")
425
self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version)
426
self.manager.dispatcher["toplevel"] = self.on_new_toplevel
427
self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
428
self.display.roundtrip()
429
self.display.flush()
430
elif interface == "wl_seat":
431
self.print_log("Seat found")
432
self.seat = registry.bind(name, WlSeat, version)
433
elif interface == "wl_compositor":
434
self.compositor = registry.bind(name, WlCompositor, version)
435
self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
436
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
437
self.get_root().get_native().get_surface().__gpointer__, None)))
438
439
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
440
handle: ZwlrForeignToplevelHandleV1):
441
handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title)
442
handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id)
443
handle.dispatcher["output_enter"] = self.on_output_entered
444
handle.dispatcher["output_leave"] = self.on_output_left
445
handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states)
446
handle.dispatcher["closed"] = lambda h: self.on_closed(h)
447
448
def on_output_entered(self, handle, output):
449
# TODO: make this configurable
450
# TODO: on wayfire, append/remove buttons when the workspace changes
451
if output != self.my_output:
452
return
453
if handle in self.toplevel_buttons:
454
button = self.toplevel_buttons[handle]
455
self.append(button)
456
self.set_all_rectangles()
457
458
def on_output_left(self, handle, output):
459
if output != self.my_output:
460
return
461
if handle in self.toplevel_buttons:
462
button = self.toplevel_buttons[handle]
463
self.remove(button)
464
self.set_all_rectangles()
465
466
def on_title_changed(self, handle, title):
467
if handle not in self.toplevel_buttons:
468
button = WindowButton(handle, title)
469
button.set_group(self.initial_button)
470
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options))
471
button.connect("clicked", self.on_button_click)
472
self.toplevel_buttons[handle] = button
473
else:
474
button = self.toplevel_buttons[handle]
475
button.window_title = title
476
477
def set_all_rectangles(self):
478
child = self.get_first_child()
479
while child is not None:
480
if isinstance(child, WindowButton):
481
surface = WlSurface()
482
surface._ptr = self.wl_surface_ptr
483
child.window_id.set_rectangle(surface, *get_widget_rect(child))
484
485
child = child.get_next_sibling()
486
487
def on_button_click(self, button: WindowButton):
488
# Set a rectangle for animation
489
surface = WlSurface()
490
surface._ptr = self.wl_surface_ptr
491
button.window_id.set_rectangle(surface, *get_widget_rect(button))
492
if button.window_state.focused:
493
# Already pressed in, so minimise the focused window
494
button.window_id.set_minimized()
495
else:
496
button.window_id.unset_minimized()
497
button.window_id.activate(self.seat)
498
499
self.display.flush()
500
501
def on_state_changed(self, handle, states):
502
if handle in self.toplevel_buttons:
503
state_info = WindowState.from_state_array(states)
504
button = self.toplevel_buttons[handle]
505
button.window_state = state_info
506
if state_info.focused:
507
button.set_active(True)
508
else:
509
self.initial_button.set_active(True)
510
511
self.set_all_rectangles()
512
513
def on_app_id_changed(self, handle, app_id):
514
if handle in self.toplevel_buttons:
515
button = self.toplevel_buttons[handle]
516
button.set_icon_from_app_id(app_id)
517
app_ids = app_id.split()
518
for app_id in app_ids:
519
if app_id.startswith("wf-ipc-"):
520
self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button
521
522
def on_closed(self, handle):
523
button: WindowButton = self.toplevel_buttons[handle]
524
wf_id = button.wf_ipc_id
525
if handle in self.toplevel_buttons:
526
self.remove(self.toplevel_buttons[handle])
527
self.toplevel_buttons.pop(handle)
528
if wf_id in self.toplevel_buttons_by_wf_id:
529
self.toplevel_buttons_by_wf_id.pop(wf_id)
530
531
self.set_all_rectangles()
532
533
def make_context_menu(self):
534
menu = Gio.Menu()
535
menu.append(_("Window list _options"), "applet.options")
536
context_menu = Gtk.PopoverMenu.new_from_model(menu)
537
context_menu.set_has_arrow(False)
538
context_menu.set_parent(self)
539
context_menu.set_halign(Gtk.Align.START)
540
context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
541
return context_menu
542
543
def show_context_menu(self, gesture, n_presses, x, y):
544
rect = Gdk.Rectangle()
545
rect.x = int(x)
546
rect.y = int(y)
547
rect.width = 1
548
rect.height = 1
549
550
self.context_menu.set_pointing_to(rect)
551
self.context_menu.popup()
552
553
def show_options(self, _0=None, _1=None):
554
if self.options_window is None:
555
self.options_window = WindowListOptions()
556
self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
557
self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
558
559
def reset_window(*args):
560
self.options_window = None
561
562
self.options_window.connect("close-request", reset_window)
563
self.options_window.present()
564
565
def update_button_options(self, adjustment):
566
self.window_button_options.max_width = adjustment.get_value()
567
child: Gtk.Widget = self.get_first_child()
568
while child:
569
child.queue_allocate()
570
child.queue_resize()
571
child.queue_draw()
572
child = child.get_next_sibling()
573
574
def get_config(self):
575
return {"max_button_width": self.window_button_options.max_width}
576
577
def output_changed(self):
578
self.get_wl_resources()
579