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 • 12.65 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
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
def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]:
54
if len(array) % size:
55
raise ValueError(f"The byte string's length must be a multiple of {size}")
56
57
values: list[int] = []
58
for i in range(0, len(array), size):
59
values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder))
60
61
return values
62
63
64
def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]:
65
width = int(widget.get_width())
66
height = int(widget.get_height())
67
68
toplevel = widget.get_root()
69
if not toplevel:
70
return None, None, width, height
71
72
x, y = widget.translate_coordinates(toplevel, 0, 0)
73
x = int(x)
74
y = int(y)
75
76
return x, y, width, height
77
78
79
@dataclasses.dataclass
80
class WindowState:
81
minimised: bool
82
maximised: bool
83
fullscreen: bool
84
focused: bool
85
86
@classmethod
87
def from_state_array(cls, array: bytes):
88
values = split_bytes_into_ints(array)
89
instance = cls(False, False, False, False)
90
for value in values:
91
match value:
92
case 0:
93
instance.maximised = True
94
case 1:
95
instance.minimised = True
96
case 2:
97
instance.focused = True
98
case 3:
99
instance.fullscreen = True
100
101
return instance
102
103
104
from gi.repository import Gtk, Gdk
105
106
class WindowButtonOptions:
107
def __init__(self, max_width: int):
108
self.max_width = max_width
109
110
class WindowButtonLayoutManager(Gtk.LayoutManager):
111
def __init__(self, options: WindowButtonOptions, **kwargs):
112
super().__init__(**kwargs)
113
self.options = options
114
115
def do_measure(self, widget, orientation, for_size):
116
child = widget.get_first_child()
117
if child is None:
118
return 0, 0, 0, 0
119
120
if orientation == Gtk.Orientation.HORIZONTAL:
121
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size)
122
width = min(nat_width, self.options.max_width)
123
return min_width, width, min_height, nat_height
124
else:
125
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size)
126
return min_height, nat_height, 0, 0
127
128
def do_allocate(self, widget, width, height, baseline):
129
child = widget.get_first_child()
130
if child is None:
131
return
132
alloc_width = min(width, self.options.max_width)
133
alloc = Gdk.Rectangle()
134
alloc.x = 0
135
alloc.y = 0
136
alloc.width = alloc_width
137
alloc.height = height
138
child.allocate(alloc.width, alloc.height, baseline)
139
140
141
class WindowButton(Gtk.ToggleButton):
142
def __init__(self, window_id, window_title, **kwargs):
143
super().__init__(**kwargs)
144
145
self.window_id: ZwlrForeignToplevelHandleV1 = window_id
146
self.set_has_frame(False)
147
self.label = Gtk.Label()
148
self.icon = Gtk.Image.new_from_icon_name("application-x-executable")
149
box = Gtk.Box()
150
box.append(self.icon)
151
box.append(self.label)
152
self.set_child(box)
153
154
self.window_title = window_title
155
self.window_state = WindowState(False, False, False, False)
156
157
self.label.set_ellipsize(Pango.EllipsizeMode.END)
158
self.set_hexpand(True)
159
self.set_vexpand(True)
160
161
@property
162
def window_title(self):
163
return self.label.get_text()
164
165
@window_title.setter
166
def window_title(self, value):
167
self.label.set_text(value)
168
169
def set_icon_from_app_id(self, app_id):
170
# Try getting an icon from the correct theme
171
app_ids = app_id.split()
172
icon_theme = Gtk.IconTheme.get_for_display(self.get_display())
173
174
for app_id in app_ids:
175
if icon_theme.has_icon(app_id):
176
self.icon.set_from_icon_name(app_id)
177
return
178
179
# If that doesn't work, try getting one from .desktop files
180
for app_id in app_ids:
181
try:
182
desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop")
183
if desktop_file:
184
self.icon.set_from_gicon(desktop_file.get_icon())
185
return
186
except TypeError:
187
# Due to a bug, the constructor may sometimes return C NULL
188
pass
189
190
191
class WFWindowList(panorama_panel.Applet):
192
name = "Wayfire window list"
193
description = "Traditional window list (for Wayfire)"
194
195
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
196
super().__init__(orientation=orientation, config=config)
197
if config is None:
198
config = {}
199
200
self.set_homogeneous(True)
201
self.window_button_options = WindowButtonOptions(240)
202
203
self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {}
204
# This button doesn't belong to any window but is used for the button group and to be
205
# selected when no window is focused
206
self.initial_button = Gtk.ToggleButton()
207
208
self.display = None
209
self.wl_surface_ptr = None
210
self.registry = None
211
self.compositor = None
212
self.seat = None
213
214
self.context_menu = self.make_context_menu()
215
panorama_panel.track_popover(self.context_menu)
216
217
right_click_controller = Gtk.GestureClick()
218
right_click_controller.set_button(3)
219
right_click_controller.connect("pressed", self.show_context_menu)
220
221
self.add_controller(right_click_controller)
222
223
action_group = Gio.SimpleActionGroup()
224
options_action = Gio.SimpleAction.new("options", None)
225
options_action.connect("activate", self.show_options)
226
action_group.add_action(options_action)
227
self.insert_action_group("applet", action_group)
228
# Wait for the widget to be in a layer-shell window before doing this
229
self.connect("realize", lambda *args: self.get_wl_resources())
230
231
self.options_window = None
232
233
def get_wl_resources(self):
234
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
235
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
236
237
self.display = Display()
238
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
239
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None)))
240
self.display._ptr = wl_display_ptr
241
242
# Intentionally commented: the display is already connected by GTK
243
# self.display.connect()
244
245
self.registry = self.display.get_registry()
246
self.registry.dispatcher["global"] = self.on_global
247
self.display.roundtrip()
248
fd = self.display.get_fd()
249
GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event)
250
251
def on_display_event(self, source, condition):
252
if condition == GLib.IO_IN:
253
self.display.dispatch(block=True)
254
return True
255
256
def on_global(self, registry, name, interface, version):
257
if interface == "zwlr_foreign_toplevel_manager_v1":
258
self.print_log("Interface registered")
259
self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version)
260
self.manager.dispatcher["toplevel"] = self.on_new_toplevel
261
self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
262
self.display.roundtrip()
263
self.display.flush()
264
elif interface == "wl_seat":
265
self.print_log("Seat found")
266
self.seat = registry.bind(name, WlSeat, version)
267
elif interface == "wl_compositor":
268
self.compositor = registry.bind(name, WlCompositor, version)
269
self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
270
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
271
self.get_root().get_native().get_surface().__gpointer__, None)))
272
273
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
274
handle: ZwlrForeignToplevelHandleV1):
275
handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title)
276
handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id)
277
handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states)
278
handle.dispatcher["closed"] = lambda h: self.on_closed(h)
279
280
def on_title_changed(self, handle, title):
281
if handle not in self.toplevel_buttons:
282
button = WindowButton(handle, title)
283
button.set_group(self.initial_button)
284
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options))
285
button.connect("clicked", self.on_button_click)
286
self.toplevel_buttons[handle] = button
287
self.append(button)
288
else:
289
button = self.toplevel_buttons[handle]
290
button.window_title = title
291
292
self.set_all_rectangles()
293
294
def set_all_rectangles(self):
295
for button in self.toplevel_buttons.values():
296
surface = WlSurface()
297
surface._ptr = self.wl_surface_ptr
298
button.window_id.set_rectangle(surface, *get_widget_rect(button))
299
300
def on_button_click(self, button: WindowButton):
301
# Set a rectangle for animation
302
surface = WlSurface()
303
surface._ptr = self.wl_surface_ptr
304
button.window_id.set_rectangle(surface, *get_widget_rect(button))
305
if button.window_state.focused:
306
# Already pressed in, so minimise the focused window
307
button.window_id.set_minimized()
308
else:
309
button.window_id.unset_minimized()
310
button.window_id.activate(self.seat)
311
312
self.display.flush()
313
314
def on_state_changed(self, handle, states):
315
if handle in self.toplevel_buttons:
316
state_info = WindowState.from_state_array(states)
317
button = self.toplevel_buttons[handle]
318
button.window_state = state_info
319
if state_info.focused:
320
button.set_active(True)
321
else:
322
self.initial_button.set_active(True)
323
324
self.set_all_rectangles()
325
326
def on_app_id_changed(self, handle, app_id):
327
if handle in self.toplevel_buttons:
328
button = self.toplevel_buttons[handle]
329
button.set_icon_from_app_id(app_id)
330
331
def on_closed(self, handle):
332
if handle in self.toplevel_buttons:
333
self.remove(self.toplevel_buttons[handle])
334
self.toplevel_buttons.pop(handle)
335
336
self.set_all_rectangles()
337
338
def make_context_menu(self):
339
menu = Gio.Menu()
340
menu.append("Window list _options", "applet.options")
341
context_menu = Gtk.PopoverMenu.new_from_model(menu)
342
context_menu.set_has_arrow(False)
343
context_menu.set_parent(self)
344
context_menu.set_halign(Gtk.Align.START)
345
context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
346
return context_menu
347
348
def show_context_menu(self, gesture, n_presses, x, y):
349
rect = Gdk.Rectangle()
350
rect.x = int(x)
351
rect.y = int(y)
352
rect.width = 1
353
rect.height = 1
354
355
self.context_menu.set_pointing_to(rect)
356
self.context_menu.popup()
357
358
def show_options(self, _0=None, _1=None):
359
pass
360
361
def get_config(self):
362
return {}