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