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 • 34.77 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 mmap
24
import locale
25
import typing
26
from pathlib import Path
27
from pywayland.utils import AnonymousFile
28
from pywayland.client import Display, EventQueue
29
from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlCompositor, WlOutput, WlBuffer, WlShm, WlShmPool
30
from pywayland.protocol.wayland.wl_output import WlOutputProxy
31
from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import (
32
ZwlrForeignToplevelManagerV1,
33
ZwlrForeignToplevelHandleV1
34
)
35
from pywayland.protocol.wlr_screencopy_unstable_v1 import (
36
ZwlrScreencopyManagerV1,
37
ZwlrScreencopyFrameV1
38
)
39
from OpenGL import GL
40
import panorama_panel
41
42
import gi
43
44
gi.require_version("Gtk", "4.0")
45
gi.require_version("GdkWayland", "4.0")
46
47
from gi.repository import Gtk, GLib, Gtk4LayerShell, Gio, Gdk, Pango, GObject
48
49
50
module_directory = Path(__file__).resolve().parent
51
52
locale.bindtextdomain("panorama-window-list", module_directory / "locale")
53
_ = lambda x: locale.dgettext("panorama-window-list", x)
54
55
56
import ctypes
57
from cffi import FFI
58
ffi = FFI()
59
ffi.cdef("""
60
void * gdk_wayland_display_get_wl_display (void * display);
61
void * gdk_wayland_surface_get_wl_surface (void * surface);
62
void * gdk_wayland_monitor_get_wl_output (void * monitor);
63
""")
64
gtk = ffi.dlopen("libgtk-4.so.1")
65
66
67
@Gtk.Template(filename=str(module_directory / "panorama-window-list-options.ui"))
68
class WindowListOptions(Gtk.Window):
69
__gtype_name__ = "WindowListOptions"
70
button_width_adjustment: Gtk.Adjustment = Gtk.Template.Child()
71
workspace_filter_checkbutton: Gtk.CheckButton = Gtk.Template.Child()
72
73
def __init__(self, **kwargs):
74
super().__init__(**kwargs)
75
76
self.connect("close-request", lambda *args: self.destroy())
77
78
79
def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]:
80
if len(array) % size:
81
raise ValueError(f"The byte string's length must be a multiple of {size}")
82
83
values: list[int] = []
84
for i in range(0, len(array), size):
85
values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder))
86
87
return values
88
89
90
def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]:
91
width = int(widget.get_width())
92
height = int(widget.get_height())
93
94
toplevel = widget.get_root()
95
if not toplevel:
96
return None, None, width, height
97
98
x, y = widget.translate_coordinates(toplevel, 0, 0)
99
x = int(x)
100
y = int(y)
101
102
return x, y, width, height
103
104
105
@dataclasses.dataclass
106
class WindowState:
107
minimised: bool
108
maximised: bool
109
fullscreen: bool
110
focused: bool
111
112
@classmethod
113
def from_state_array(cls, array: bytes):
114
values = split_bytes_into_ints(array)
115
instance = cls(False, False, False, False)
116
for value in values:
117
match value:
118
case 0:
119
instance.maximised = True
120
case 1:
121
instance.minimised = True
122
case 2:
123
instance.focused = True
124
case 3:
125
instance.fullscreen = True
126
127
return instance
128
129
130
from gi.repository import Gtk, Gdk
131
132
class WindowButtonOptions:
133
def __init__(self, max_width: int):
134
self.max_width = max_width
135
136
class WindowButtonLayoutManager(Gtk.LayoutManager):
137
def __init__(self, options: WindowButtonOptions, **kwargs):
138
super().__init__(**kwargs)
139
self.options = options
140
141
def do_measure(self, widget, orientation, for_size):
142
child = widget.get_first_child()
143
if child is None:
144
return 0, 0, 0, 0
145
146
if orientation == Gtk.Orientation.HORIZONTAL:
147
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size)
148
width = self.options.max_width
149
return min_width, width, min_height, nat_height
150
else:
151
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size)
152
return min_height, nat_height, 0, 0
153
154
def do_allocate(self, widget, width, height, baseline):
155
child = widget.get_first_child()
156
if child is None:
157
return
158
alloc_width = min(width, self.options.max_width)
159
alloc = Gdk.Rectangle()
160
alloc.x = 0
161
alloc.y = 0
162
alloc.width = alloc_width
163
alloc.height = height
164
child.allocate(alloc.width, alloc.height, baseline)
165
166
167
class WindowButton(Gtk.ToggleButton):
168
def __init__(self, window_id, window_title, window_list, **kwargs):
169
super().__init__(**kwargs)
170
171
self.window_id: ZwlrForeignToplevelHandleV1 = window_id
172
self.wf_sock = window_list.wf_socket
173
self.window_list = window_list
174
self.wf_ipc_id: typing.Optional[int] = None
175
self.set_has_frame(False)
176
self.label = Gtk.Label()
177
self.icon = Gtk.Image.new_from_icon_name("application-x-executable")
178
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
179
box.append(self.icon)
180
box.append(self.label)
181
self.set_child(box)
182
183
self.window_title = window_title
184
self.window_state = WindowState(False, False, False, False)
185
186
self.label.set_ellipsize(Pango.EllipsizeMode.END)
187
self.set_hexpand(True)
188
self.set_vexpand(True)
189
190
self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE)
191
self.drag_source.connect("prepare", self.provide_drag_data)
192
self.drag_source.connect("drag-begin", self.drag_begin)
193
self.drag_source.connect("drag-cancel", self.drag_cancel)
194
195
self.add_controller(self.drag_source)
196
197
self.menu = Gio.Menu()
198
# TODO: toggle the labels when needed
199
self.minimise_item = Gio.MenuItem.new(_("_Minimise"), "button.minimise")
200
self.maximise_item = Gio.MenuItem.new(_("Ma_ximise"), "button.maximise")
201
self.menu.append_item(self.minimise_item)
202
self.menu.append_item(self.maximise_item)
203
self.menu.append(_("_Close"), "button.close")
204
self.menu.append(_("Window list _options"), "applet.options")
205
self.popover_menu = Gtk.PopoverMenu.new_from_model(self.menu)
206
self.popover_menu.set_parent(self)
207
self.popover_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
208
self.popover_menu.set_has_arrow(False)
209
self.popover_menu.set_halign(Gtk.Align.END)
210
211
self.right_click_controller = Gtk.GestureClick(button=3)
212
self.right_click_controller.connect("pressed", self.show_menu)
213
self.add_controller(self.right_click_controller)
214
215
self.action_group = Gio.SimpleActionGroup()
216
close_action = Gio.SimpleAction.new("close")
217
close_action.connect("activate", self.close_associated)
218
self.action_group.insert(close_action)
219
minimise_action = Gio.SimpleAction.new("minimise")
220
minimise_action.connect("activate", self.minimise_associated)
221
self.action_group.insert(minimise_action)
222
maximise_action = Gio.SimpleAction.new("maximise")
223
maximise_action.connect("activate", self.maximise_associated)
224
self.action_group.insert(maximise_action)
225
226
self.insert_action_group("button", self.action_group)
227
228
self.middle_click_controller = Gtk.GestureClick(button=2)
229
self.middle_click_controller.connect("released", self.close_associated)
230
self.add_controller(self.middle_click_controller)
231
232
if not self.window_list.live_preview_tooltips:
233
return
234
235
self.custom_tooltip_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
236
self.custom_tooltip_content.append(TooltipMedia(window_list))
237
self.hover_point = 0, 0
238
self.hover_timeout = None
239
self.connect("query-tooltip", self.query_tooltip)
240
self.set_has_tooltip(True)
241
motion_controller = Gtk.EventControllerMotion()
242
motion_controller.connect("enter", self.on_button_enter)
243
motion_controller.connect("leave", self.on_button_leave)
244
self.add_controller(motion_controller)
245
246
def query_tooltip(self, widget, x, y, keyboard_mode, tooltip):
247
tooltip.set_custom(self.custom_tooltip_content)
248
return True
249
250
def on_button_enter(self, button, x, y):
251
view_id = self.wf_ipc_id
252
self.window_list.set_live_preview_output_name("live-preview-" + str(view_id))
253
message = self.window_list.get_msg_template("live_previews/request_stream")
254
message["data"]["id"] = view_id
255
self.wf_sock.send_json(message)
256
257
def on_button_leave(self, button):
258
message = self.window_list.get_msg_template("live_previews/release_output")
259
self.wf_sock.send_json(message)
260
261
def show_menu(self, gesture, n_presses, x, y):
262
rect = Gdk.Rectangle()
263
rect.x = int(x)
264
rect.y = int(y)
265
rect.width = 1
266
rect.height = 1
267
self.popover_menu.popup()
268
269
def close_associated(self, *args):
270
self.window_id.close()
271
272
def minimise_associated(self, action, *args):
273
if self.window_state.minimised:
274
self.window_id.unset_minimized()
275
else:
276
self.window_id.set_minimized()
277
278
def maximise_associated(self, action, *args):
279
if self.window_state.maximised:
280
self.window_id.unset_maximized()
281
else:
282
self.window_id.set_maximized()
283
284
def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float):
285
app = self.get_root().get_application()
286
app.drags[id(self)] = self
287
value = GObject.Value()
288
value.init(GObject.TYPE_UINT64)
289
value.set_uint64(id(self))
290
return Gdk.ContentProvider.new_for_value(value)
291
292
def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag):
293
paintable = Gtk.WidgetPaintable.new(self).get_current_image()
294
source.set_icon(paintable, 0, 0)
295
self.hide()
296
297
def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason):
298
self.show()
299
return False
300
301
@property
302
def window_title(self):
303
return self.label.get_text()
304
305
@window_title.setter
306
def window_title(self, value):
307
self.label.set_text(value)
308
309
def set_icon_from_app_id(self, app_id):
310
app_ids = app_id.split()
311
312
# If on Wayfire, find the IPC ID
313
for app_id in app_ids:
314
if app_id.startswith("wf-ipc-"):
315
self.wf_ipc_id = int(app_id.removeprefix("wf-ipc-"))
316
break
317
318
# Try getting an icon from the correct theme
319
icon_theme = Gtk.IconTheme.get_for_display(self.get_display())
320
321
for app_id in app_ids:
322
if icon_theme.has_icon(app_id):
323
self.icon.set_from_icon_name(app_id)
324
return
325
326
# If that doesn't work, try getting one from .desktop files
327
for app_id in app_ids:
328
try:
329
desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop")
330
if desktop_file:
331
self.icon.set_from_gicon(desktop_file.get_icon())
332
return
333
except TypeError:
334
# Due to a bug, the constructor may sometimes return C NULL
335
pass
336
337
class TooltipMedia(Gtk.GLArea):
338
def __init__(self, window_list):
339
super().__init__()
340
self.frame: ZwlrScreencopyFrameV1 = None
341
self.screencopy_manager = window_list.screencopy_manager
342
self.wl_shm = window_list.wl_shm
343
self.window_list = window_list
344
self.set_auto_render(False)
345
self.connect("render", self.on_render)
346
347
self.buffer_width = 200
348
self.buffer_height = 200
349
350
self.screencopy_data = None
351
self.wl_buffer = None
352
self.size = 0
353
354
self.add_tick_callback(self.on_tick)
355
356
self.vertices = [
357
1.0, 1.0,
358
-1.0, 1.0,
359
-1.0, -1.0,
360
1.0, -1.0,
361
]
362
self.uv_coords = [
363
1.0, 0.0,
364
0.0, 0.0,
365
0.0, 1.0,
366
1.0, 1.0,
367
]
368
vertex_shader_source = """
369
precision highp float;
370
attribute vec2 in_position;
371
attribute vec2 uvpos;
372
varying vec2 uv;
373
374
void main() {
375
uv = uvpos;
376
gl_Position = vec4(in_position, 0.0, 1.0);
377
}
378
"""
379
380
fragment_shader_source = """
381
precision highp float;
382
uniform sampler2D texture;
383
384
varying vec2 uv;
385
386
void main() {
387
gl_FragColor = texture2D(texture, uv);
388
}
389
"""
390
vertex_shader = GL.glCreateShader(GL.GL_VERTEX_SHADER)
391
fragment_shader = GL.glCreateShader(GL.GL_FRAGMENT_SHADER)
392
GL.glShaderSource(vertex_shader, vertex_shader_source)
393
GL.glShaderSource(fragment_shader, fragment_shader_source)
394
GL.glCompileShader(vertex_shader)
395
GL.glCompileShader(fragment_shader)
396
self.shader_program = GL.glCreateProgram()
397
GL.glAttachShader(self.shader_program, vertex_shader)
398
GL.glAttachShader(self.shader_program, fragment_shader)
399
GL.glLinkProgram(self.shader_program)
400
401
self.texture_id = GL.glGenTextures(1)
402
GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture_id)
403
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
404
GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR)
405
self.request_next_frame()
406
407
408
def frame_handle_buffer(self, frame, pixel_format, width, height, stride):
409
size = width * height * int(stride / width)
410
if self.size != size:
411
self.set_size_request(width, height)
412
self.size = size
413
anon_file = AnonymousFile(self.size)
414
anon_file.open()
415
fd = anon_file.fd
416
self.screencopy_data = mmap.mmap(fileno=fd, length=self.size, access=(mmap.ACCESS_READ | mmap.ACCESS_WRITE), offset=0)
417
pool = self.wl_shm.create_pool(fd, self.size)
418
self.wl_buffer = pool.create_buffer(0, width, height, stride, pixel_format)
419
pool.destroy()
420
anon_file.close()
421
self.buffer_width = width
422
self.buffer_height = height
423
self.frame.copy(self.wl_buffer)
424
425
def frame_ready(self, frame, tv_sec_hi, tv_sec_low, tv_nsec):
426
self.screencopy_data.seek(0)
427
GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture_id)
428
GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA, self.buffer_width, self.buffer_height, 0, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, self.screencopy_data.read(self.size))
429
self.request_next_frame()
430
431
def request_next_frame(self):
432
if self.frame:
433
self.frame.destroy()
434
435
self.queue_render()
436
437
wl_output = self.window_list.get_live_preview_output()
438
439
if not wl_output:
440
return
441
442
self.frame = self.screencopy_manager.capture_output(1, wl_output)
443
self.frame.dispatcher["buffer"] = self.frame_handle_buffer
444
self.frame.dispatcher["ready"] = self.frame_ready
445
446
def on_render(self, gl_area, gl_context):
447
Gdk.GLContext.make_current(gl_context)
448
GL.glClear(GL.GL_COLOR_BUFFER_BIT)
449
GL.glUseProgram(self.shader_program)
450
GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture_id)
451
GL.glEnableVertexAttribArray(0);
452
GL.glVertexAttribPointer(0, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, self.vertices);
453
GL.glEnableVertexAttribArray(1);
454
GL.glVertexAttribPointer(1, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, self.uv_coords);
455
GL.glDrawArrays(GL.GL_TRIANGLE_FAN, 0, 4);
456
GL.glDisableVertexAttribArray(0);
457
GL.glDisableVertexAttribArray(1);
458
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
459
gl_area.queue_draw()
460
461
def on_tick(self, widget, frame_clock):
462
self.request_next_frame()
463
return GLib.SOURCE_CONTINUE
464
465
class WFWindowList(panorama_panel.Applet):
466
name = _("Wayfire window list")
467
description = _("Traditional window list (for Wayfire and other wlroots compositors)")
468
469
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
470
super().__init__(orientation=orientation, config=config)
471
if config is None:
472
config = {}
473
474
self.set_homogeneous(True)
475
self.window_button_options = WindowButtonOptions(config.get("max_button_width", 256))
476
self.show_only_this_wf_workspace = config.get("show_only_this_wf_workspace", True)
477
self.show_only_this_output = config.get("show_only_this_output", True)
478
self.icon_size = config.get("icon_size", 24)
479
480
self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {}
481
self.toplevel_buttons_by_wf_id: dict[int, WindowButton] = {}
482
# This button doesn't belong to any window but is used for the button group and to be
483
# selected when no window is focused
484
self.initial_button = Gtk.ToggleButton()
485
486
self.display = None
487
self.my_output = None
488
self.wl_surface_ptr = None
489
self.registry = None
490
self.compositor = None
491
self.seat = None
492
493
self.context_menu = self.make_context_menu()
494
panorama_panel.track_popover(self.context_menu)
495
496
right_click_controller = Gtk.GestureClick()
497
right_click_controller.set_button(3)
498
right_click_controller.connect("pressed", self.show_context_menu)
499
500
self.add_controller(right_click_controller)
501
502
action_group = Gio.SimpleActionGroup()
503
options_action = Gio.SimpleAction.new("options", None)
504
options_action.connect("activate", self.show_options)
505
action_group.add_action(options_action)
506
self.insert_action_group("applet", action_group)
507
# Wait for the widget to be in a layer-shell window before doing this
508
self.connect("realize", self.get_wl_resources)
509
510
self.options_window = None
511
512
# Support button reordering
513
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
514
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
515
self.drop_target.connect("drop", self.drop_button)
516
517
self.add_controller(self.drop_target)
518
519
# Make a Wayfire socket for workspace handling
520
self.live_preview_tooltips = False
521
try:
522
import wayfire
523
from wayfire.core.template import get_msg_template
524
self.wf_socket = wayfire.WayfireSocket()
525
self.get_msg_template = get_msg_template
526
if "live_previews/request_stream" in self.wf_socket.list_methods():
527
self.live_preview_tooltips = True
528
self.wf_socket.watch(["view-workspace-changed", "wset-workspace-changed"])
529
fd = self.wf_socket.client.fileno()
530
GLib.io_add_watch(GLib.IOChannel.unix_new(fd), GLib.IO_IN, self.on_wf_event, priority=GLib.PRIORITY_HIGH)
531
except:
532
# Wayfire raises Exception itself, so it cannot be narrowed down
533
self.wf_socket = None
534
535
def get_wf_output_by_name(self, name):
536
if not self.wf_socket:
537
return None
538
for output in self.wf_socket.list_outputs():
539
if output["name"] == name:
540
return output
541
return None
542
543
def on_wf_event(self, source, condition):
544
if condition & GLib.IO_IN:
545
try:
546
message = self.wf_socket.read_next_event()
547
event = message.get("event")
548
match event:
549
case "view-workspace-changed":
550
view = message.get("view", {})
551
if view["output-name"] == self.get_root().monitor_name:
552
output = self.get_wf_output_by_name(view["output-name"])
553
current_workspace = output["workspace"]["x"], output["workspace"]["y"]
554
button = self.toplevel_buttons_by_wf_id[view["id"]]
555
if not self.show_only_this_wf_workspace or (message["to"]["x"], message["to"]["y"]) == current_workspace:
556
if button.get_parent() is None and button.output == self.my_output:
557
self.append(button)
558
else:
559
if button.get_parent() is self:
560
# Remove out-of-workspace window
561
self.remove(button)
562
case "wset-workspace-changed":
563
output_name = self.get_root().monitor_name
564
if message["wset-data"]["output-name"] == output_name:
565
# It has changed on this monitor; refresh the window list
566
self.filter_to_wf_workspace()
567
568
except Exception as e:
569
print("Error reading Wayfire event:", e)
570
return True
571
572
def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
573
button: WindowButton = self.get_root().get_application().drags.pop(value)
574
if button.get_parent() is not self:
575
# Prevent dropping a button from another window list
576
return False
577
578
self.remove(button)
579
# Find the position where to insert the applet
580
# Probably we could use the assumption that buttons are homogeneous here for efficiency
581
child = self.get_first_child()
582
while child:
583
allocation = child.get_allocation()
584
child_x, child_y = self.translate_coordinates(self, 0, 0)
585
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
586
midpoint = child_x + allocation.width / 2
587
if x < midpoint:
588
button.insert_before(self, child)
589
break
590
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
591
midpoint = child_y + allocation.height / 2
592
if y < midpoint:
593
button.insert_before(self, child)
594
break
595
child = child.get_next_sibling()
596
else:
597
self.append(button)
598
button.show()
599
600
self.set_all_rectangles()
601
return True
602
603
def filter_to_wf_workspace(self):
604
if not self.show_only_this_wf_workspace:
605
return
606
607
output = self.get_wf_output_by_name(self.get_root().monitor_name)
608
for wf_id, button in self.toplevel_buttons_by_wf_id.items():
609
view = self.wf_socket.get_view(wf_id)
610
mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
611
mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
612
output_width = output["geometry"]["width"]
613
output_height = output["geometry"]["height"]
614
if 0 <= mid_x < output_width and 0 <= mid_y < output_height and button.output == self.my_output:
615
# It is in this workspace; keep it
616
if button.get_parent() is None:
617
self.append(button)
618
else:
619
# Remove it from this window list
620
if button.get_parent() is self:
621
self.remove(button)
622
623
def get_wl_resources(self, widget):
624
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
625
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
626
627
self.display = Display()
628
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
629
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None)))
630
self.display._ptr = wl_display_ptr
631
self.event_queue = EventQueue(self.display)
632
633
# Intentionally commented: the display is already connected by GTK
634
# self.display.connect()
635
636
my_monitor = Gtk4LayerShell.get_monitor(self.get_root())
637
638
# Iterate through monitors and get their Wayland output (wl_output)
639
# This is a hack to ensure output_enter/leave is called for toplevels
640
for monitor in self.get_root().get_application().monitors:
641
wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
642
if wl_output and not monitor.output_proxy:
643
print("Create proxy")
644
output_proxy = WlOutputProxy(wl_output, self.display)
645
output_proxy.interface.registry[output_proxy._ptr] = output_proxy
646
monitor.output_proxy = output_proxy
647
648
if monitor == my_monitor:
649
self.my_output = monitor.output_proxy
650
# End hack
651
652
self.output = None
653
self.wl_shm = None
654
self.manager = None
655
self.screencopy_manager = None
656
self.live_preview_output_name = None
657
self.registry = self.display.get_registry()
658
self.registry.dispatcher["global"] = self.on_global
659
self.display.roundtrip()
660
if self.manager is None:
661
print("Could not load wf-window-list. Is foreign-toplevel protocol advertised by the compositor?")
662
print("(Wayfire requires enabling foreign-toplevel plugin)")
663
return
664
if self.screencopy_manager is None:
665
print("Protocol zwlr_screencopy_manager_v1 not found, live (minimized) window previews will not work!")
666
self.manager.dispatcher["toplevel"] = self.on_new_toplevel
667
self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
668
self.display.roundtrip()
669
fd = self.display.get_fd()
670
GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event)
671
672
if self.wf_socket is not None:
673
self.filter_to_wf_workspace()
674
675
def on_display_event(self, source, condition):
676
if condition & GLib.IO_IN:
677
self.display.dispatch(queue=self.event_queue)
678
return True
679
680
def set_live_preview_output(self, output):
681
self.output = output
682
683
def get_live_preview_output(self):
684
return self.output
685
686
def set_live_preview_output_name(self, name):
687
self.live_preview_output_name = name
688
689
def get_live_preview_output_name(self):
690
return self.live_preview_output_name
691
692
def wl_output_handle_name(self, output, name):
693
if name != self.get_live_preview_output_name():
694
output.release()
695
output.destroy()
696
return
697
self.set_live_preview_output(output)
698
699
def on_global(self, registry, name, interface, version):
700
if interface == "zwlr_foreign_toplevel_manager_v1":
701
self.print_log("Interface registered")
702
self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version)
703
elif interface == "wl_output":
704
if self.live_preview_output_name is None:
705
return
706
wl_output = registry.bind(name, WlOutput, version)
707
wl_output.dispatcher["name"] = self.wl_output_handle_name
708
elif interface == "wl_seat":
709
self.print_log("Seat found")
710
self.seat = registry.bind(name, WlSeat, version)
711
elif interface == "wl_shm":
712
self.wl_shm = registry.bind(name, WlShm, version)
713
elif interface == "wl_compositor":
714
self.compositor = registry.bind(name, WlCompositor, version)
715
wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
716
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
717
self.get_root().get_native().get_surface().__gpointer__, None)))
718
self.wl_surface = WlSurface()
719
self.wl_surface._ptr = wl_surface_ptr
720
elif interface == "zwlr_screencopy_manager_v1":
721
self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version)
722
723
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
724
handle: ZwlrForeignToplevelHandleV1):
725
handle.dispatcher["title"] = self.on_title_changed
726
handle.dispatcher["app_id"] = self.on_app_id_changed
727
handle.dispatcher["output_enter"] = self.on_output_entered
728
handle.dispatcher["output_leave"] = self.on_output_left
729
handle.dispatcher["state"] = self.on_state_changed
730
handle.dispatcher["closed"] = self.on_closed
731
self.display.roundtrip()
732
733
def on_output_entered(self, handle, output):
734
button = WindowButton(handle, handle.title, self)
735
button.icon.set_pixel_size(self.icon_size)
736
button.output = None
737
738
if self.show_only_this_output and output != self.my_output:
739
return
740
741
button.output = output
742
self.set_title(button, handle.title)
743
button.set_group(self.initial_button)
744
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options))
745
button.connect("clicked", self.on_button_click)
746
self.set_app_id(button, handle.app_id)
747
self.toplevel_buttons[handle] = button
748
self.append(button)
749
self.set_all_rectangles()
750
751
def on_output_left(self, handle, output):
752
if handle in self.toplevel_buttons:
753
button = self.toplevel_buttons[handle]
754
if button.get_parent() == self:
755
button.output = None
756
self.remove(button)
757
self.set_all_rectangles()
758
759
def set_title(self, button, title):
760
button.window_title = title
761
if not self.live_preview_tooltips:
762
button.set_tooltip_text(title)
763
764
def on_title_changed(self, handle, title):
765
handle.title = title
766
if handle in self.toplevel_buttons:
767
button = self.toplevel_buttons[handle]
768
self.set_title(button, title)
769
770
def set_all_rectangles(self):
771
child = self.get_first_child()
772
while child is not None:
773
if isinstance(child, WindowButton):
774
child.window_id.set_rectangle(self.wl_surface, *get_widget_rect(child))
775
776
child = child.get_next_sibling()
777
778
def on_button_click(self, button: WindowButton):
779
# Set a rectangle for animation
780
button.window_id.set_rectangle(self.wl_surface, *get_widget_rect(button))
781
if button.window_state.minimised:
782
button.window_id.activate(self.seat)
783
else:
784
if button.window_state.focused:
785
button.window_id.set_minimized()
786
else:
787
button.window_id.activate(self.seat)
788
789
def on_state_changed(self, handle, states):
790
if handle in self.toplevel_buttons:
791
state_info = WindowState.from_state_array(states)
792
button = self.toplevel_buttons[handle]
793
button.window_state = state_info
794
if state_info.focused:
795
button.set_active(True)
796
else:
797
self.initial_button.set_active(True)
798
799
self.set_all_rectangles()
800
801
def set_app_id(self, button, app_id):
802
button.app_id = app_id
803
button.set_icon_from_app_id(app_id)
804
app_ids = app_id.split()
805
for app_id in app_ids:
806
if app_id.startswith("wf-ipc-"):
807
self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button
808
809
def on_app_id_changed(self, handle, app_id):
810
handle.app_id = app_id
811
if handle in self.toplevel_buttons:
812
button = self.toplevel_buttons[handle]
813
self.set_app_id(button, app_id)
814
815
def on_closed(self, handle):
816
if handle not in self.toplevel_buttons:
817
return
818
button: WindowButton = self.toplevel_buttons[handle]
819
wf_id = button.wf_ipc_id
820
if handle in self.toplevel_buttons:
821
self.remove(self.toplevel_buttons[handle])
822
self.toplevel_buttons.pop(handle)
823
if wf_id in self.toplevel_buttons_by_wf_id:
824
self.toplevel_buttons_by_wf_id.pop(wf_id)
825
826
self.set_all_rectangles()
827
828
def make_context_menu(self):
829
menu = Gio.Menu()
830
menu.append(_("Window list _options"), "applet.options")
831
context_menu = Gtk.PopoverMenu.new_from_model(menu)
832
context_menu.set_has_arrow(False)
833
context_menu.set_parent(self)
834
context_menu.set_halign(Gtk.Align.START)
835
context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
836
return context_menu
837
838
def show_context_menu(self, gesture, n_presses, x, y):
839
rect = Gdk.Rectangle()
840
rect.x = int(x)
841
rect.y = int(y)
842
rect.width = 1
843
rect.height = 1
844
845
self.context_menu.set_pointing_to(rect)
846
self.context_menu.popup()
847
848
def show_options(self, _0=None, _1=None):
849
if self.options_window is None:
850
self.options_window = WindowListOptions()
851
self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
852
self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
853
self.options_window.workspace_filter_checkbutton.set_active(self.show_only_this_wf_workspace)
854
self.options_window.workspace_filter_checkbutton.connect("toggled", self.update_workspace_filter)
855
856
def reset_window(*args):
857
self.options_window = None
858
859
self.options_window.connect("close-request", reset_window)
860
self.options_window.present()
861
862
def remove_workspace_filtering(self):
863
for button in self.toplevel_buttons.values():
864
if button.get_parent() is None:
865
self.append(button)
866
867
def update_workspace_filter(self, checkbutton):
868
self.show_only_this_wf_workspace = checkbutton.get_active()
869
if checkbutton.get_active():
870
self.filter_to_wf_workspace()
871
else:
872
self.remove_workspace_filtering()
873
874
def update_button_options(self, adjustment):
875
self.window_button_options.max_width = adjustment.get_value()
876
child: Gtk.Widget = self.get_first_child()
877
while child:
878
child.queue_allocate()
879
child.queue_resize()
880
child.queue_draw()
881
child = child.get_next_sibling()
882
883
self.emit("config-changed")
884
885
def get_config(self):
886
return {
887
"max_button_width": self.window_button_options.max_width,
888
"show_only_this_wf_workspace": self.show_only_this_wf_workspace,
889
"show_only_this_output": self.show_only_this_output,
890
}
891
892
def output_changed(self):
893
self.get_wl_resources()
894
895
def make_draggable(self):
896
for button in self.toplevel_buttons.values():
897
button.remove_controller(button.drag_source)
898
button.set_sensitive(False)
899
900
def restore_drag(self):
901
for button in self.toplevel_buttons.values():
902
button.add_controller(button.drag_source)
903
button.set_sensitive(True)
904