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 • 24.38 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 = 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
self.menu = Gio.Menu()
188
self.minimise_item = Gio.MenuItem.new(_("_Minimise"), "button.minimise")
189
self.maximise_item = Gio.MenuItem.new(_("Ma_ximise"), "button.maximise")
190
self.menu.append_item(self.minimise_item)
191
self.menu.append_item(self.maximise_item)
192
self.menu.append(_("_Close"), "button.close")
193
self.popover_menu = Gtk.PopoverMenu.new_from_model(self.menu)
194
self.popover_menu.set_parent(self)
195
self.popover_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
196
self.popover_menu.set_has_arrow(False)
197
self.popover_menu.set_halign(Gtk.Align.END)
198
199
self.right_click_controller = Gtk.GestureClick(button=3)
200
self.right_click_controller.connect("pressed", self.show_menu)
201
self.add_controller(self.right_click_controller)
202
203
self.action_group = Gio.SimpleActionGroup()
204
close_action = Gio.SimpleAction.new("close")
205
close_action.connect("activate", self.close_associated)
206
self.action_group.insert(close_action)
207
minimise_action = Gio.SimpleAction.new("minimise")
208
minimise_action.connect("activate", self.minimise_associated)
209
self.action_group.insert(minimise_action)
210
maximise_action = Gio.SimpleAction.new("maximise")
211
maximise_action.connect("activate", self.maximise_associated)
212
self.action_group.insert(maximise_action)
213
214
self.insert_action_group("button", self.action_group)
215
216
self.middle_click_controller = Gtk.GestureClick(button=2)
217
self.middle_click_controller.connect("released", self.close_associated)
218
self.add_controller(self.middle_click_controller)
219
220
def show_menu(self, gesture, n_presses, x, y):
221
rect = Gdk.Rectangle()
222
rect.x = int(x)
223
rect.y = int(y)
224
rect.width = 1
225
rect.height = 1
226
self.popover_menu.popup()
227
228
def close_associated(self, *args):
229
self.window_id.close()
230
231
def minimise_associated(self, action, *args):
232
if self.window_state.minimised:
233
self.window_id.unset_minimized()
234
else:
235
self.window_id.set_minimized()
236
237
def maximise_associated(self, action, *args):
238
if self.window_state.maximised:
239
self.window_id.unset_maximized()
240
else:
241
self.window_id.set_maximized()
242
243
def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float):
244
app = self.get_root().get_application()
245
app.drags[id(self)] = self
246
value = GObject.Value()
247
value.init(GObject.TYPE_UINT64)
248
value.set_uint64(id(self))
249
return Gdk.ContentProvider.new_for_value(value)
250
251
def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag):
252
paintable = Gtk.WidgetPaintable.new(self).get_current_image()
253
source.set_icon(paintable, 0, 0)
254
self.hide()
255
256
def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason):
257
self.show()
258
return False
259
260
@property
261
def window_title(self):
262
return self.label.get_text()
263
264
@window_title.setter
265
def window_title(self, value):
266
self.label.set_text(value)
267
268
def set_icon_from_app_id(self, app_id):
269
app_ids = app_id.split()
270
271
# If on Wayfire, find the IPC ID
272
for app_id in app_ids:
273
if app_id.startswith("wf-ipc-"):
274
self.wf_ipc_id = int(app_id.removeprefix("wf-ipc-"))
275
break
276
277
# Try getting an icon from the correct theme
278
icon_theme = Gtk.IconTheme.get_for_display(self.get_display())
279
280
for app_id in app_ids:
281
if icon_theme.has_icon(app_id):
282
self.icon.set_from_icon_name(app_id)
283
return
284
285
# If that doesn't work, try getting one from .desktop files
286
for app_id in app_ids:
287
try:
288
desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop")
289
if desktop_file:
290
self.icon.set_from_gicon(desktop_file.get_icon())
291
return
292
except TypeError:
293
# Due to a bug, the constructor may sometimes return C NULL
294
pass
295
296
297
class WFWindowList(panorama_panel.Applet):
298
name = _("Wayfire window list")
299
description = _("Traditional window list (for Wayfire and other wlroots compositors)")
300
301
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
302
super().__init__(orientation=orientation, config=config)
303
if config is None:
304
config = {}
305
306
self.set_homogeneous(True)
307
self.window_button_options = WindowButtonOptions(config.get("max_button_width", 256))
308
309
self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {}
310
self.toplevel_buttons_by_wf_id: dict[int, WindowButton] = {}
311
# This button doesn't belong to any window but is used for the button group and to be
312
# selected when no window is focused
313
self.initial_button = Gtk.ToggleButton()
314
315
self.display = None
316
self.my_output = None
317
self.wl_surface_ptr = None
318
self.registry = None
319
self.compositor = None
320
self.seat = None
321
322
self.context_menu = self.make_context_menu()
323
panorama_panel.track_popover(self.context_menu)
324
325
right_click_controller = Gtk.GestureClick()
326
right_click_controller.set_button(3)
327
right_click_controller.connect("pressed", self.show_context_menu)
328
329
self.add_controller(right_click_controller)
330
331
action_group = Gio.SimpleActionGroup()
332
options_action = Gio.SimpleAction.new("options", None)
333
options_action.connect("activate", self.show_options)
334
action_group.add_action(options_action)
335
self.insert_action_group("applet", action_group)
336
# Wait for the widget to be in a layer-shell window before doing this
337
self.connect("realize", lambda *args: self.get_wl_resources())
338
339
self.options_window = None
340
341
# Support button reordering
342
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
343
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
344
self.drop_target.connect("drop", self.drop_button)
345
346
self.add_controller(self.drop_target)
347
348
# Make a Wayfire socket for workspace handling
349
try:
350
import wayfire
351
self.wf_socket = wayfire.WayfireSocket()
352
self.wf_socket.watch()
353
fd = self.wf_socket.client.fileno()
354
GLib.io_add_watch(GLib.IOChannel.unix_new(fd), GLib.IO_IN, self.on_wf_event, priority=GLib.PRIORITY_HIGH)
355
except:
356
# Wayfire raises Exception itself, so it cannot be narrowed down
357
self.wf_socket = None
358
359
def on_wf_event(self, source, condition):
360
if condition & GLib.IO_IN:
361
try:
362
message = self.wf_socket.read_next_event()
363
event = message.get("event")
364
match event:
365
case "view-workspace-changed":
366
view = message.get("view", {})
367
output = self.wf_socket.get_output(self.get_root().monitor_index + 1)
368
current_workspace = output["workspace"]["x"], output["workspace"]["y"]
369
if (message["to"]["x"], message["to"]["y"]) == current_workspace:
370
if self.toplevel_buttons_by_wf_id[view["id"]].get_parent() is None:
371
self.append(self.toplevel_buttons_by_wf_id[view["id"]])
372
else:
373
if self.toplevel_buttons_by_wf_id[view["id"]].get_parent() is self:
374
# Remove out-of-workspace window
375
self.remove(self.toplevel_buttons_by_wf_id[view["id"]])
376
case "wset-workspace-changed":
377
output_id = self.get_root().monitor_index + 1
378
if message["wset-data"]["output-id"] == output_id:
379
# It has changed on this monitor; refresh the window list
380
self.filter_to_wf_workspace()
381
382
except Exception as e:
383
print("Error reading Wayfire event:", e)
384
return True
385
386
def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
387
button: WindowButton = self.get_root().get_application().drags.pop(value)
388
if button.get_parent() is not self:
389
# Prevent dropping a button from another window list
390
return False
391
392
self.remove(button)
393
# Find the position where to insert the applet
394
# Probably we could use the assumption that buttons are homogeneous here for efficiency
395
child = self.get_first_child()
396
while child:
397
allocation = child.get_allocation()
398
child_x, child_y = self.translate_coordinates(self, 0, 0)
399
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
400
midpoint = child_x + allocation.width / 2
401
if x < midpoint:
402
button.insert_before(self, child)
403
break
404
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
405
midpoint = child_y + allocation.height / 2
406
if y < midpoint:
407
button.insert_before(self, child)
408
break
409
child = child.get_next_sibling()
410
else:
411
self.append(button)
412
button.show()
413
414
self.set_all_rectangles()
415
return True
416
417
def filter_to_wf_workspace(self):
418
output = self.wf_socket.get_output(self.get_root().monitor_index + 1)
419
for wf_id, button in self.toplevel_buttons_by_wf_id.items():
420
view = self.wf_socket.get_view(wf_id)
421
mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
422
mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
423
output_width = output["geometry"]["width"]
424
output_height = output["geometry"]["height"]
425
if 0 <= mid_x < output_width and 0 <= mid_y < output_height:
426
# It is in this workspace; keep it
427
if button.get_parent() is None:
428
self.append(button)
429
else:
430
# Remove it from this window list
431
if button.get_parent() is self:
432
self.remove(button)
433
434
def get_wl_resources(self):
435
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
436
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
437
438
self.display = Display()
439
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
440
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None)))
441
self.display._ptr = wl_display_ptr
442
self.event_queue = EventQueue(self.display)
443
444
# Intentionally commented: the display is already connected by GTK
445
# self.display.connect()
446
447
my_monitor = Gtk4LayerShell.get_monitor(self.get_root())
448
449
# Iterate through monitors and get their Wayland output (wl_output)
450
# This is a hack to ensure output_enter/leave is called for toplevels
451
for monitor in self.get_root().get_native().get_display().get_monitors():
452
wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
453
if wl_output:
454
print("Create proxy")
455
output_proxy = WlOutputProxy(wl_output, self.display)
456
output_proxy.interface.registry[output_proxy._ptr] = output_proxy
457
458
if monitor == my_monitor:
459
self.my_output = output_proxy
460
# End hack
461
462
self.registry = self.display.get_registry()
463
self.registry.dispatcher["global"] = self.on_global
464
self.display.roundtrip()
465
fd = self.display.get_fd()
466
GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event)
467
468
if self.wf_socket is not None:
469
self.filter_to_wf_workspace()
470
471
def on_display_event(self, source, condition):
472
if condition & GLib.IO_IN:
473
self.display.dispatch(queue=self.event_queue)
474
return True
475
476
def on_global(self, registry, name, interface, version):
477
if interface == "zwlr_foreign_toplevel_manager_v1":
478
self.print_log("Interface registered")
479
self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version)
480
self.manager.dispatcher["toplevel"] = self.on_new_toplevel
481
self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
482
self.display.roundtrip()
483
self.display.flush()
484
elif interface == "wl_seat":
485
self.print_log("Seat found")
486
self.seat = registry.bind(name, WlSeat, version)
487
elif interface == "wl_compositor":
488
self.compositor = registry.bind(name, WlCompositor, version)
489
self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
490
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
491
self.get_root().get_native().get_surface().__gpointer__, None)))
492
493
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
494
handle: ZwlrForeignToplevelHandleV1):
495
handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title)
496
handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id)
497
handle.dispatcher["output_enter"] = self.on_output_entered
498
handle.dispatcher["output_leave"] = self.on_output_left
499
handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states)
500
handle.dispatcher["closed"] = lambda h: self.on_closed(h)
501
502
def on_output_entered(self, handle, output):
503
# TODO: make this configurable
504
# TODO: on wayfire, append/remove buttons when the workspace changes
505
if output != self.my_output:
506
return
507
if handle in self.toplevel_buttons:
508
button = self.toplevel_buttons[handle]
509
self.append(button)
510
self.set_all_rectangles()
511
512
def on_output_left(self, handle, output):
513
if output != self.my_output:
514
return
515
if handle in self.toplevel_buttons:
516
button = self.toplevel_buttons[handle]
517
self.remove(button)
518
self.set_all_rectangles()
519
520
def on_title_changed(self, handle, title):
521
if handle not in self.toplevel_buttons:
522
button = WindowButton(handle, title)
523
button.set_group(self.initial_button)
524
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options))
525
button.connect("clicked", self.on_button_click)
526
self.toplevel_buttons[handle] = button
527
else:
528
button = self.toplevel_buttons[handle]
529
button.window_title = title
530
531
def set_all_rectangles(self):
532
child = self.get_first_child()
533
while child is not None:
534
if isinstance(child, WindowButton):
535
surface = WlSurface()
536
surface._ptr = self.wl_surface_ptr
537
child.window_id.set_rectangle(surface, *get_widget_rect(child))
538
539
child = child.get_next_sibling()
540
541
def on_button_click(self, button: WindowButton):
542
# Set a rectangle for animation
543
surface = WlSurface()
544
surface._ptr = self.wl_surface_ptr
545
button.window_id.set_rectangle(surface, *get_widget_rect(button))
546
if button.window_state.focused:
547
# Already pressed in, so minimise the focused window
548
button.window_id.set_minimized()
549
else:
550
button.window_id.unset_minimized()
551
button.window_id.activate(self.seat)
552
553
self.display.flush()
554
555
def on_state_changed(self, handle, states):
556
if handle in self.toplevel_buttons:
557
state_info = WindowState.from_state_array(states)
558
button = self.toplevel_buttons[handle]
559
button.window_state = state_info
560
if state_info.focused:
561
button.set_active(True)
562
else:
563
self.initial_button.set_active(True)
564
565
self.set_all_rectangles()
566
567
def on_app_id_changed(self, handle, app_id):
568
if handle in self.toplevel_buttons:
569
button = self.toplevel_buttons[handle]
570
button.set_icon_from_app_id(app_id)
571
app_ids = app_id.split()
572
for app_id in app_ids:
573
if app_id.startswith("wf-ipc-"):
574
self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button
575
576
def on_closed(self, handle):
577
button: WindowButton = self.toplevel_buttons[handle]
578
wf_id = button.wf_ipc_id
579
if handle in self.toplevel_buttons:
580
self.remove(self.toplevel_buttons[handle])
581
self.toplevel_buttons.pop(handle)
582
if wf_id in self.toplevel_buttons_by_wf_id:
583
self.toplevel_buttons_by_wf_id.pop(wf_id)
584
585
self.set_all_rectangles()
586
587
def make_context_menu(self):
588
menu = Gio.Menu()
589
menu.append(_("Window list _options"), "applet.options")
590
context_menu = Gtk.PopoverMenu.new_from_model(menu)
591
context_menu.set_has_arrow(False)
592
context_menu.set_parent(self)
593
context_menu.set_halign(Gtk.Align.START)
594
context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
595
return context_menu
596
597
def show_context_menu(self, gesture, n_presses, x, y):
598
rect = Gdk.Rectangle()
599
rect.x = int(x)
600
rect.y = int(y)
601
rect.width = 1
602
rect.height = 1
603
604
self.context_menu.set_pointing_to(rect)
605
self.context_menu.popup()
606
607
def show_options(self, _0=None, _1=None):
608
if self.options_window is None:
609
self.options_window = WindowListOptions()
610
self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
611
self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
612
613
def reset_window(*args):
614
self.options_window = None
615
616
self.options_window.connect("close-request", reset_window)
617
self.options_window.present()
618
619
def update_button_options(self, adjustment):
620
self.window_button_options.max_width = adjustment.get_value()
621
child: Gtk.Widget = self.get_first_child()
622
while child:
623
child.queue_allocate()
624
child.queue_resize()
625
child.queue_draw()
626
child = child.get_next_sibling()
627
628
def get_config(self):
629
return {"max_button_width": self.window_button_options.max_width}
630
631
def output_changed(self):
632
self.get_wl_resources()
633