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