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 • 16.34 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
from pathlib import Path
24
from pywayland.client import Display
25
from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlCompositor
26
from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import (
27
ZwlrForeignToplevelManagerV1,
28
ZwlrForeignToplevelHandleV1
29
)
30
import panorama_panel
31
32
import gi
33
34
gi.require_version("Gtk", "4.0")
35
gi.require_version("GdkWayland", "4.0")
36
37
from gi.repository import Gtk, GLib, Gtk4LayerShell, Gio, Gdk, Pango, GObject
38
39
40
import ctypes
41
from cffi import FFI
42
ffi = FFI()
43
ffi.cdef("""
44
void * gdk_wayland_display_get_wl_display (void * display);
45
void * gdk_wayland_surface_get_wl_surface (void * surface);
46
""")
47
gtk = ffi.dlopen("libgtk-4.so.1")
48
49
50
module_directory = Path(__file__).resolve().parent
51
52
53
@Gtk.Template(filename=str(module_directory / "panorama-window-list-options.ui"))
54
class WindowListOptions(Gtk.Window):
55
__gtype_name__ = "WindowListOptions"
56
button_width_adjustment: Gtk.Adjustment = Gtk.Template.Child()
57
58
def __init__(self, **kwargs):
59
super().__init__(**kwargs)
60
61
self.connect("close-request", lambda *args: self.destroy())
62
63
64
def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]:
65
if len(array) % size:
66
raise ValueError(f"The byte string's length must be a multiple of {size}")
67
68
values: list[int] = []
69
for i in range(0, len(array), size):
70
values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder))
71
72
return values
73
74
75
def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]:
76
width = int(widget.get_width())
77
height = int(widget.get_height())
78
79
toplevel = widget.get_root()
80
if not toplevel:
81
return None, None, width, height
82
83
x, y = widget.translate_coordinates(toplevel, 0, 0)
84
x = int(x)
85
y = int(y)
86
87
return x, y, width, height
88
89
90
@dataclasses.dataclass
91
class WindowState:
92
minimised: bool
93
maximised: bool
94
fullscreen: bool
95
focused: bool
96
97
@classmethod
98
def from_state_array(cls, array: bytes):
99
values = split_bytes_into_ints(array)
100
instance = cls(False, False, False, False)
101
for value in values:
102
match value:
103
case 0:
104
instance.maximised = True
105
case 1:
106
instance.minimised = True
107
case 2:
108
instance.focused = True
109
case 3:
110
instance.fullscreen = True
111
112
return instance
113
114
115
from gi.repository import Gtk, Gdk
116
117
class WindowButtonOptions:
118
def __init__(self, max_width: int):
119
self.max_width = max_width
120
121
class WindowButtonLayoutManager(Gtk.LayoutManager):
122
def __init__(self, options: WindowButtonOptions, **kwargs):
123
super().__init__(**kwargs)
124
self.options = options
125
126
def do_measure(self, widget, orientation, for_size):
127
child = widget.get_first_child()
128
if child is None:
129
return 0, 0, 0, 0
130
131
if orientation == Gtk.Orientation.HORIZONTAL:
132
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size)
133
width = min(nat_width, self.options.max_width)
134
return min_width, width, min_height, nat_height
135
else:
136
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size)
137
return min_height, nat_height, 0, 0
138
139
def do_allocate(self, widget, width, height, baseline):
140
child = widget.get_first_child()
141
if child is None:
142
return
143
alloc_width = min(width, self.options.max_width)
144
alloc = Gdk.Rectangle()
145
alloc.x = 0
146
alloc.y = 0
147
alloc.width = alloc_width
148
alloc.height = height
149
child.allocate(alloc.width, alloc.height, baseline)
150
151
152
class WindowButton(Gtk.ToggleButton):
153
def __init__(self, window_id, window_title, **kwargs):
154
super().__init__(**kwargs)
155
156
self.window_id: ZwlrForeignToplevelHandleV1 = window_id
157
self.set_has_frame(False)
158
self.label = Gtk.Label()
159
self.icon = Gtk.Image.new_from_icon_name("application-x-executable")
160
box = Gtk.Box()
161
box.append(self.icon)
162
box.append(self.label)
163
self.set_child(box)
164
165
self.window_title = window_title
166
self.window_state = WindowState(False, False, False, False)
167
168
self.label.set_ellipsize(Pango.EllipsizeMode.END)
169
self.set_hexpand(True)
170
self.set_vexpand(True)
171
172
self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE)
173
self.drag_source.connect("prepare", self.provide_drag_data)
174
self.drag_source.connect("drag-begin", self.drag_begin)
175
self.drag_source.connect("drag-cancel", self.drag_cancel)
176
177
self.add_controller(self.drag_source)
178
179
def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float):
180
app = self.get_root().get_application()
181
app.drags[id(self)] = self
182
value = GObject.Value()
183
value.init(GObject.TYPE_UINT64)
184
value.set_uint64(id(self))
185
return Gdk.ContentProvider.new_for_value(value)
186
187
def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag):
188
paintable = Gtk.WidgetPaintable.new(self).get_current_image()
189
source.set_icon(paintable, 0, 0)
190
self.hide()
191
192
def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason):
193
self.show()
194
return False
195
196
@property
197
def window_title(self):
198
return self.label.get_text()
199
200
@window_title.setter
201
def window_title(self, value):
202
self.label.set_text(value)
203
204
def set_icon_from_app_id(self, app_id):
205
# Try getting an icon from the correct theme
206
app_ids = app_id.split()
207
icon_theme = Gtk.IconTheme.get_for_display(self.get_display())
208
209
for app_id in app_ids:
210
if icon_theme.has_icon(app_id):
211
self.icon.set_from_icon_name(app_id)
212
return
213
214
# If that doesn't work, try getting one from .desktop files
215
for app_id in app_ids:
216
try:
217
desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop")
218
if desktop_file:
219
self.icon.set_from_gicon(desktop_file.get_icon())
220
return
221
except TypeError:
222
# Due to a bug, the constructor may sometimes return C NULL
223
pass
224
225
226
class WFWindowList(panorama_panel.Applet):
227
name = "Wayfire window list"
228
description = "Traditional window list (for Wayfire)"
229
230
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
231
super().__init__(orientation=orientation, config=config)
232
if config is None:
233
config = {}
234
235
self.set_homogeneous(True)
236
self.window_button_options = WindowButtonOptions(240)
237
238
self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {}
239
# This button doesn't belong to any window but is used for the button group and to be
240
# selected when no window is focused
241
self.initial_button = Gtk.ToggleButton()
242
243
self.display = None
244
self.wl_surface_ptr = None
245
self.registry = None
246
self.compositor = None
247
self.seat = None
248
249
self.context_menu = self.make_context_menu()
250
panorama_panel.track_popover(self.context_menu)
251
252
right_click_controller = Gtk.GestureClick()
253
right_click_controller.set_button(3)
254
right_click_controller.connect("pressed", self.show_context_menu)
255
256
self.add_controller(right_click_controller)
257
258
action_group = Gio.SimpleActionGroup()
259
options_action = Gio.SimpleAction.new("options", None)
260
options_action.connect("activate", self.show_options)
261
action_group.add_action(options_action)
262
self.insert_action_group("applet", action_group)
263
# Wait for the widget to be in a layer-shell window before doing this
264
self.connect("realize", lambda *args: self.get_wl_resources())
265
266
self.options_window = None
267
268
# Support button reordering
269
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
270
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
271
self.drop_target.connect("drop", self.drop_button)
272
273
self.add_controller(self.drop_target)
274
275
def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
276
button: WindowButton = self.get_root().get_application().drags.pop(value)
277
if button.get_parent() is not self:
278
# Prevent dropping a button from another window list
279
return False
280
281
self.remove(button)
282
# Find the position where to insert the applet
283
# Probably we could use the assumption that buttons are homogeneous here for efficiency
284
child = self.get_first_child()
285
while child:
286
allocation = child.get_allocation()
287
child_x, child_y = self.translate_coordinates(self, 0, 0)
288
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
289
midpoint = child_x + allocation.width / 2
290
if x < midpoint:
291
button.insert_before(self, child)
292
break
293
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
294
midpoint = child_y + allocation.height / 2
295
if y < midpoint:
296
button.insert_before(self, child)
297
break
298
child = child.get_next_sibling()
299
else:
300
self.append(button)
301
button.show()
302
303
self.set_all_rectangles()
304
return True
305
306
def get_wl_resources(self):
307
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
308
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
309
310
self.display = Display()
311
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
312
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None)))
313
self.display._ptr = wl_display_ptr
314
315
# Intentionally commented: the display is already connected by GTK
316
# self.display.connect()
317
318
self.registry = self.display.get_registry()
319
self.registry.dispatcher["global"] = self.on_global
320
self.display.roundtrip()
321
fd = self.display.get_fd()
322
GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event)
323
324
def on_display_event(self, source, condition):
325
if condition == GLib.IO_IN:
326
self.display.dispatch(block=True)
327
return True
328
329
def on_global(self, registry, name, interface, version):
330
if interface == "zwlr_foreign_toplevel_manager_v1":
331
self.print_log("Interface registered")
332
self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version)
333
self.manager.dispatcher["toplevel"] = self.on_new_toplevel
334
self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
335
self.display.roundtrip()
336
self.display.flush()
337
elif interface == "wl_seat":
338
self.print_log("Seat found")
339
self.seat = registry.bind(name, WlSeat, version)
340
elif interface == "wl_compositor":
341
self.compositor = registry.bind(name, WlCompositor, version)
342
self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
343
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
344
self.get_root().get_native().get_surface().__gpointer__, None)))
345
346
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
347
handle: ZwlrForeignToplevelHandleV1):
348
handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title)
349
handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id)
350
handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states)
351
handle.dispatcher["closed"] = lambda h: self.on_closed(h)
352
353
def on_title_changed(self, handle, title):
354
if handle not in self.toplevel_buttons:
355
button = WindowButton(handle, title)
356
button.set_group(self.initial_button)
357
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options))
358
button.connect("clicked", self.on_button_click)
359
self.toplevel_buttons[handle] = button
360
self.append(button)
361
else:
362
button = self.toplevel_buttons[handle]
363
button.window_title = title
364
365
self.set_all_rectangles()
366
367
def set_all_rectangles(self):
368
for button in self.toplevel_buttons.values():
369
surface = WlSurface()
370
surface._ptr = self.wl_surface_ptr
371
button.window_id.set_rectangle(surface, *get_widget_rect(button))
372
373
def on_button_click(self, button: WindowButton):
374
# Set a rectangle for animation
375
surface = WlSurface()
376
surface._ptr = self.wl_surface_ptr
377
button.window_id.set_rectangle(surface, *get_widget_rect(button))
378
if button.window_state.focused:
379
# Already pressed in, so minimise the focused window
380
button.window_id.set_minimized()
381
else:
382
button.window_id.unset_minimized()
383
button.window_id.activate(self.seat)
384
385
self.display.flush()
386
387
def on_state_changed(self, handle, states):
388
if handle in self.toplevel_buttons:
389
state_info = WindowState.from_state_array(states)
390
button = self.toplevel_buttons[handle]
391
button.window_state = state_info
392
if state_info.focused:
393
button.set_active(True)
394
else:
395
self.initial_button.set_active(True)
396
397
self.set_all_rectangles()
398
399
def on_app_id_changed(self, handle, app_id):
400
if handle in self.toplevel_buttons:
401
button = self.toplevel_buttons[handle]
402
button.set_icon_from_app_id(app_id)
403
404
def on_closed(self, handle):
405
if handle in self.toplevel_buttons:
406
self.remove(self.toplevel_buttons[handle])
407
self.toplevel_buttons.pop(handle)
408
409
self.set_all_rectangles()
410
411
def make_context_menu(self):
412
menu = Gio.Menu()
413
menu.append("Window list _options", "applet.options")
414
context_menu = Gtk.PopoverMenu.new_from_model(menu)
415
context_menu.set_has_arrow(False)
416
context_menu.set_parent(self)
417
context_menu.set_halign(Gtk.Align.START)
418
context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
419
return context_menu
420
421
def show_context_menu(self, gesture, n_presses, x, y):
422
rect = Gdk.Rectangle()
423
rect.x = int(x)
424
rect.y = int(y)
425
rect.width = 1
426
rect.height = 1
427
428
self.context_menu.set_pointing_to(rect)
429
self.context_menu.popup()
430
431
def show_options(self, _0=None, _1=None):
432
if self.options_window is None:
433
self.options_window = WindowListOptions()
434
self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
435
self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
436
437
def reset_window(*args):
438
self.options_window = None
439
440
self.options_window.connect("close-request", reset_window)
441
self.options_window.present()
442
443
def update_button_options(self, adjustment):
444
self.window_button_options.max_width = adjustment.get_value()
445
child: Gtk.Widget = self.get_first_child()
446
while child:
447
child.queue_allocate()
448
child.queue_resize()
449
child.queue_draw()
450
child = child.get_next_sibling()
451
452
def get_config(self):
453
return {}