Important information: Google announced that, from September 2026, Android devices will require ALL apps to be signed by Google, effectively leading to an iOS situation. Value your right to a computer that does what you want; do not tolerate this monopolistic practice! Contact me if you don't understand why it is bad. Click to learn more.

 __init__.py

View raw Download
text/plain • 31.53 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, 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
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, GioUnix
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 0, 0, 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, button: Gtk.ToggleButton, **kwargs):
138
super().__init__(**kwargs)
139
self.options = options
140
self.button = button
141
142
def do_measure(self, widget, orientation, for_size):
143
child = widget.get_first_child()
144
if child is None:
145
return 0, 0, 0, 0
146
147
if orientation == Gtk.Orientation.HORIZONTAL:
148
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size)
149
width = self.options.max_width
150
return min_width, width, min_height, nat_height
151
else:
152
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size)
153
return min_height, nat_height, 0, 0
154
155
def do_allocate(self, widget, width, height, baseline):
156
child = widget.get_first_child()
157
if child is None:
158
return
159
alloc_width = min(width, self.options.max_width)
160
alloc = Gdk.Rectangle()
161
alloc.x = 0
162
alloc.y = 0
163
alloc.width = alloc_width
164
alloc.height = height
165
child.allocate(alloc.width, alloc.height, baseline)
166
if alloc.width > 0 and alloc.height > 0 and self.button.window_list.wl_surface:
167
x, y = widget.translate_coordinates(self.button.get_root(), 0, 0)
168
x = int(x)
169
y = int(y)
170
self.button.window_id.set_rectangle(self.button.window_list.wl_surface, x, y, alloc.width, alloc.height)
171
172
class WindowButton(Gtk.ToggleButton):
173
def __init__(self, window_id, window_title, window_list, **kwargs):
174
super().__init__(**kwargs)
175
176
self.window_id: ZwlrForeignToplevelHandleV1 = window_id
177
self.wf_sock = window_list.wf_socket
178
self.window_list = window_list
179
self.wf_ipc_id: typing.Optional[int] = None
180
self.popover_open = False
181
self.set_has_frame(False)
182
self.label = Gtk.Label()
183
self.icon = Gtk.Image.new_from_icon_name("application-x-executable")
184
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
185
box.append(self.icon)
186
box.append(self.label)
187
self.set_child(box)
188
189
self.window_title = window_title
190
self.window_state = WindowState(False, False, False, False)
191
192
self.label.set_ellipsize(Pango.EllipsizeMode.END)
193
self.set_hexpand(True)
194
self.set_vexpand(True)
195
196
self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE, propagation_phase=Gtk.PropagationPhase.CAPTURE)
197
self.drag_source.connect("prepare", self.provide_drag_data)
198
self.drag_source.connect("drag-begin", self.drag_begin)
199
self.drag_source.connect("drag-cancel", self.drag_cancel)
200
201
self.add_controller(self.drag_source)
202
203
self.menu = Gio.Menu()
204
# TODO: toggle the labels when needed
205
self.minimise_item = Gio.MenuItem.new(_("_Minimise"), "button.minimise")
206
self.maximise_item = Gio.MenuItem.new(_("Ma_ximise"), "button.maximise")
207
self.menu.append_item(self.minimise_item)
208
self.menu.append_item(self.maximise_item)
209
self.menu.append(_("_Close"), "button.close")
210
self.menu.append(_("Window list _options"), "applet.options")
211
self.popover_menu = Gtk.PopoverMenu.new_from_model(self.menu)
212
self.popover_menu.set_parent(self)
213
self.popover_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
214
self.popover_menu.set_has_arrow(False)
215
self.popover_menu.set_halign(Gtk.Align.END)
216
self.popover_menu.connect("closed", self.hide_menu)
217
218
self.right_click_controller = Gtk.GestureClick(button=3)
219
self.right_click_controller.connect("pressed", self.show_menu)
220
self.add_controller(self.right_click_controller)
221
222
self.action_group = Gio.SimpleActionGroup()
223
close_action = Gio.SimpleAction.new("close")
224
close_action.connect("activate", self.close_associated)
225
self.action_group.insert(close_action)
226
minimise_action = Gio.SimpleAction.new("minimise")
227
minimise_action.connect("activate", self.minimise_associated)
228
self.action_group.insert(minimise_action)
229
maximise_action = Gio.SimpleAction.new("maximise")
230
maximise_action.connect("activate", self.maximise_associated)
231
self.action_group.insert(maximise_action)
232
233
self.insert_action_group("button", self.action_group)
234
235
self.middle_click_controller = Gtk.GestureClick(button=2)
236
self.middle_click_controller.connect("released", self.close_associated)
237
self.add_controller(self.middle_click_controller)
238
239
if not self.window_list.live_preview_tooltips:
240
return
241
242
self.custom_tooltip_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
243
self.custom_tooltip_content.append(TooltipMedia(window_list))
244
self.hover_point = 0, 0
245
self.hover_timeout = None
246
self.connect("query-tooltip", self.query_tooltip)
247
self.set_has_tooltip(True)
248
motion_controller = Gtk.EventControllerMotion()
249
motion_controller.connect("enter", self.on_button_enter)
250
motion_controller.connect("leave", self.on_button_leave)
251
self.add_controller(motion_controller)
252
253
def query_tooltip(self, widget, x, y, keyboard_mode, tooltip):
254
if self.window_list.get_live_preview_output() is None:
255
return False
256
tooltip.set_custom(self.custom_tooltip_content)
257
return True
258
259
def on_button_enter(self, button, x, y):
260
if self.popover_open:
261
return
262
view_id = self.wf_ipc_id
263
message = self.window_list.get_msg_template("live_previews/request_stream")
264
message["data"]["id"] = view_id
265
self.wf_sock.send_json(message)
266
267
def on_button_leave(self, button):
268
if self.popover_open:
269
return
270
message = self.window_list.get_msg_template("live_previews/release_output")
271
self.wf_sock.send_json(message)
272
273
def show_menu(self, gesture, n_presses, x, y):
274
if self.window_list.live_preview_tooltips:
275
self.on_button_leave(None)
276
self.popover_open = True
277
self.popover_menu.popup()
278
279
def hide_menu(self, popover):
280
self.popover_open = False
281
self.popover_menu.popdown()
282
283
def close_associated(self, *args):
284
self.window_id.close()
285
286
def minimise_associated(self, action, *args):
287
if self.window_state.minimised:
288
self.window_id.unset_minimized()
289
else:
290
self.window_id.set_minimized()
291
292
def maximise_associated(self, action, *args):
293
if self.window_state.maximised:
294
self.window_id.unset_maximized()
295
else:
296
self.window_id.set_maximized()
297
298
def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float):
299
app = self.get_root().get_application()
300
app.drags[id(self)] = self
301
value = GObject.Value()
302
value.init(GObject.TYPE_UINT64)
303
value.set_uint64(id(self))
304
return Gdk.ContentProvider.new_for_value(value)
305
306
def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag):
307
paintable = Gtk.WidgetPaintable.new(self).get_current_image()
308
source.set_icon(paintable, 0, 0)
309
self.hide()
310
311
def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason):
312
self.show()
313
return False
314
315
@property
316
def window_title(self):
317
return self.label.get_text()
318
319
@window_title.setter
320
def window_title(self, value):
321
self.label.set_text(value)
322
323
def set_icon_from_app_id(self, app_id):
324
app_ids = app_id.split()
325
326
# If on Wayfire, find the IPC ID
327
for app_id in app_ids:
328
if app_id.startswith("wf-ipc-"):
329
self.wf_ipc_id = int(app_id.removeprefix("wf-ipc-"))
330
break
331
332
# Try getting an icon from the correct theme
333
icon_theme = Gtk.IconTheme.get_for_display(self.get_display())
334
335
for app_id in app_ids:
336
if icon_theme.has_icon(app_id):
337
self.icon.set_from_icon_name(app_id)
338
return
339
340
# If that doesn't work, try getting one from .desktop files
341
for app_id in app_ids:
342
try:
343
desktop_file = GioUnix.DesktopAppInfo.new(app_id + ".desktop")
344
if desktop_file:
345
self.icon.set_from_gicon(desktop_file.get_icon())
346
return
347
except TypeError:
348
# Due to a bug, the constructor may sometimes return C NULL
349
pass
350
351
class TooltipMedia(Gtk.Picture):
352
def __init__(self, window_list):
353
super().__init__()
354
self.frame: ZwlrScreencopyFrameV1 = None
355
self.screencopy_manager = window_list.screencopy_manager
356
self.wl_shm = window_list.wl_shm
357
self.window_list = window_list
358
359
self.buffer_width = 200
360
self.buffer_height = 200
361
self.buffer_stride = 200 * 4
362
363
self.screencopy_data = None
364
self.wl_buffer = None
365
self.size = 0
366
367
self.add_tick_callback(self.on_tick)
368
369
def frame_handle_buffer(self, frame, pixel_format, width, height, stride):
370
size = width * height * int(stride / width)
371
if self.size != size:
372
self.set_size_request(width, height)
373
self.size = size
374
anon_file = AnonymousFile(self.size)
375
anon_file.open()
376
fd = anon_file.fd
377
self.screencopy_data = mmap.mmap(fileno=fd, length=self.size, access=(mmap.ACCESS_READ | mmap.ACCESS_WRITE), offset=0)
378
pool = self.wl_shm.create_pool(fd, self.size)
379
self.wl_buffer = pool.create_buffer(0, width, height, stride, pixel_format)
380
pool.destroy()
381
anon_file.close()
382
self.buffer_width = width
383
self.buffer_height = height
384
self.buffer_stride = stride
385
self.frame.copy(self.wl_buffer)
386
387
def frame_ready(self, frame, tv_sec_hi, tv_sec_low, tv_nsec):
388
self.screencopy_data.seek(0)
389
texture = Gdk.MemoryTexture.new(
390
width=self.buffer_width,
391
height=self.buffer_height,
392
format=Gdk.MemoryFormat.R8G8B8A8,
393
bytes=GLib.Bytes(self.screencopy_data.read(self.size)),
394
stride=self.buffer_stride
395
)
396
self.set_paintable(texture)
397
398
def request_next_frame(self):
399
if self.frame:
400
self.frame.destroy()
401
402
wl_output = self.window_list.get_live_preview_output()
403
404
if not wl_output:
405
return
406
407
self.frame = self.screencopy_manager.capture_output(1, wl_output)
408
self.frame.dispatcher["buffer"] = self.frame_handle_buffer
409
self.frame.dispatcher["ready"] = self.frame_ready
410
411
def on_tick(self, widget, clock):
412
self.request_next_frame()
413
return GLib.SOURCE_CONTINUE
414
415
class WFWindowList(panorama_panel.Applet):
416
name = _("Wayfire window list")
417
description = _("Traditional window list (for Wayfire and other wlroots compositors)")
418
icon = Gio.ThemedIcon.new("preferences-system-windows")
419
420
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None, **kwargs):
421
super().__init__(orientation=orientation, config=config, **kwargs)
422
if config is None:
423
config = {}
424
425
self.set_homogeneous(True)
426
self.window_button_options = WindowButtonOptions(config.get("max_button_width", 256))
427
self.show_only_this_wf_workspace = config.get("show_only_this_wf_workspace", True)
428
self.show_only_this_output = config.get("show_only_this_output", True)
429
self.icon_size = config.get("icon_size", 24)
430
431
self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {}
432
self.toplevel_buttons_by_wf_id: dict[int, WindowButton] = {}
433
# This button doesn't belong to any window but is used for the button group and to be
434
# selected when no window is focused
435
self.initial_button = Gtk.ToggleButton()
436
437
self.my_output = None
438
self.wl_surface = None
439
self.compositor = None
440
self.wl_seat = None
441
self.output = None
442
self.wl_shm = None
443
#self.manager = None
444
self.screencopy_manager = None
445
self.live_preview_output_name = None
446
447
self.context_menu = self.make_context_menu()
448
panorama_panel.track_popover(self.context_menu)
449
450
right_click_controller = Gtk.GestureClick()
451
right_click_controller.set_button(3)
452
right_click_controller.connect("pressed", self.show_context_menu)
453
454
self.add_controller(right_click_controller)
455
456
action_group = Gio.SimpleActionGroup()
457
options_action = Gio.SimpleAction.new("options", None)
458
options_action.connect("activate", self.show_options)
459
action_group.add_action(options_action)
460
self.insert_action_group("applet", action_group)
461
# Wait for the widget to be in a layer-shell window before doing this
462
self.connect("realize", self.get_wl_resources)
463
464
self.options_window = None
465
466
# Support button reordering
467
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
468
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
469
self.drop_target.connect("drop", self.drop_button)
470
471
self.add_controller(self.drop_target)
472
473
# Make a Wayfire socket for workspace handling
474
self.live_preview_tooltips = False
475
try:
476
import wayfire
477
from wayfire.core.template import get_msg_template
478
self.wf_socket = wayfire.WayfireSocket()
479
self.get_msg_template = get_msg_template
480
if "live_previews/request_stream" in self.wf_socket.list_methods():
481
self.live_preview_tooltips = True
482
print("WFWindowList: Live Preview Tooltips: enabled")
483
else:
484
print("WFWindowList: Live Preview Tooltips: disabled")
485
print("- Live preview tooltips requires wayfire wf-live-previews plugin. Is it enabled?")
486
self.wf_socket.watch(["view-workspace-changed", "wset-workspace-changed"])
487
fd = self.wf_socket.client.fileno()
488
GLib.io_add_watch(GLib.IOChannel.unix_new(fd), GLib.IO_IN, self.on_wf_event, priority=GLib.PRIORITY_HIGH)
489
except:
490
# Wayfire raises Exception itself, so it cannot be narrowed down
491
self.wf_socket = None
492
493
def get_wf_output_by_name(self, name):
494
if not self.wf_socket:
495
return None
496
for output in self.wf_socket.list_outputs():
497
if output["name"] == name:
498
return output
499
return None
500
501
def on_wf_event(self, source, condition):
502
if condition & GLib.IO_IN:
503
try:
504
message = self.wf_socket.read_next_event()
505
event = message.get("event")
506
match event:
507
case "view-workspace-changed":
508
view = message.get("view", {})
509
if view["output-name"] == self.get_root().monitor_name:
510
output = self.get_wf_output_by_name(view["output-name"])
511
current_workspace = output["workspace"]["x"], output["workspace"]["y"]
512
button = self.toplevel_buttons_by_wf_id[view["id"]]
513
if not self.show_only_this_wf_workspace or (message["to"]["x"], message["to"]["y"]) == current_workspace:
514
if button.get_parent() is None and button.output == self.my_output:
515
self.append(button)
516
else:
517
if button.get_parent() is self:
518
# Remove out-of-workspace window
519
self.remove(button)
520
case "wset-workspace-changed":
521
output_name = self.get_root().monitor_name
522
if message["wset-data"]["output-name"] == output_name:
523
# It has changed on this monitor; refresh the window list
524
self.filter_to_wf_workspace()
525
526
except Exception as e:
527
print("Error reading Wayfire event:", e)
528
return True
529
530
def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
531
button: WindowButton = self.get_root().get_application().drags.pop(value)
532
if button.get_parent() is not self:
533
# Prevent dropping a button from another window list
534
return False
535
536
self.remove(button)
537
# Find the position where to insert the applet
538
# Probably we could use the assumption that buttons are homogeneous here for efficiency
539
print(f"drop received at {x}")
540
child = self.get_first_child()
541
while child:
542
if isinstance(child, WindowButton):
543
allocation = child.get_allocation()
544
child_x, child_y = child.translate_coordinates(self, 0, 0)
545
print(child_x, child_y)
546
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
547
midpoint = child_x + allocation.width / 2
548
print(midpoint)
549
if x < midpoint:
550
print("chosen")
551
button.insert_before(self, child)
552
break
553
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
554
midpoint = child_y + allocation.height / 2
555
if y < midpoint:
556
button.insert_before(self, child)
557
break
558
child = child.get_next_sibling()
559
else:
560
self.append(button)
561
button.show()
562
563
self.set_all_rectangles()
564
return True
565
566
def filter_to_wf_workspace(self):
567
if not self.show_only_this_wf_workspace:
568
return
569
570
output = self.get_wf_output_by_name(self.get_root().monitor_name)
571
if not output:
572
return
573
for wf_id, button in self.toplevel_buttons_by_wf_id.items():
574
view = self.wf_socket.get_view(wf_id)
575
mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
576
mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
577
output_width = output["geometry"]["width"]
578
output_height = output["geometry"]["height"]
579
if 0 <= mid_x < output_width and 0 <= mid_y < output_height and button.output == self.my_output:
580
# It is in this workspace; keep it
581
if button.get_parent() is None:
582
self.append(button)
583
else:
584
# Remove it from this window list
585
if button.get_parent() is self:
586
self.remove(button)
587
588
def get_wl_resources(self, widget):
589
if self.wf_socket is not None:
590
self.filter_to_wf_workspace()
591
592
if self.wl_shm:
593
return
594
595
self.my_output = None
596
self.wl_seat = self.get_root().get_application().wl_seat
597
self.output = None
598
self.wl_shm = self.get_root().get_application().wl_shm
599
self.screencopy_manager = self.get_root().get_application().screencopy_manager
600
self.live_preview_output_name = None
601
602
def set_live_preview_output(self, output):
603
self.output = output
604
605
def get_live_preview_output(self):
606
return self.output
607
608
def wl_output_enter(self, output, name):
609
if name.startswith("live-preview"):
610
self.set_live_preview_output(output)
611
return
612
if name.startswith("live-preview"):
613
return
614
if not self.my_output and name == self.get_root().monitor_name:
615
self.wl_surface = None
616
if self.get_root().get_native().get_surface():
617
wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
618
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
619
self.get_root().get_native().get_surface().__gpointer__, None)))
620
self.wl_surface = WlSurface()
621
self.wl_surface._ptr = wl_surface_ptr
622
self.my_output = output
623
else:
624
toplevel_buttons = self.toplevel_buttons.copy()
625
for handle in toplevel_buttons:
626
button = self.toplevel_buttons[handle]
627
if output != button.output:
628
self.foreign_toplevel_closed(handle)
629
630
def foreign_toplevel_output_enter(self, handle, output):
631
if self.show_only_this_output and (not hasattr(output, "name") or output.name != self.get_root().monitor_name):
632
return
633
634
button = WindowButton(handle, handle.title, self)
635
button.icon.set_pixel_size(self.icon_size)
636
button.output = output
637
self.set_title(button, handle.title)
638
button.set_group(self.initial_button)
639
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options, button))
640
button.connect("clicked", self.on_button_click)
641
self.set_app_id(button, handle.app_id)
642
self.toplevel_buttons[handle] = button
643
if handle.app_id.rsplit(maxsplit=1)[-1].startswith("wf-ipc-"):
644
wf_output = self.get_wf_output_by_name(self.get_root().monitor_name)
645
wf_id = int(handle.app_id.rsplit(maxsplit=1)[-1].removeprefix("wf-ipc-"))
646
view = self.wf_socket.get_view(wf_id)
647
mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
648
mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
649
output_width = wf_output["geometry"]["width"]
650
output_height = wf_output["geometry"]["height"]
651
if 0 <= mid_x < output_width and 0 <= mid_y < output_height and button.output == self.my_output:
652
# It is in this workspace; keep it
653
if button.get_parent() is None:
654
self.append(button)
655
else:
656
self.append(button)
657
658
def foreign_toplevel_output_leave(self, handle, output):
659
if handle in self.toplevel_buttons:
660
button = self.toplevel_buttons[handle]
661
if button.get_parent() == self:
662
button.output = None
663
self.remove(button)
664
self.set_all_rectangles()
665
666
def set_title(self, button, title):
667
button.window_title = title
668
if not self.live_preview_tooltips:
669
button.set_tooltip_text(title)
670
671
def foreign_toplevel_title(self, handle, title):
672
handle.title = title
673
if handle in self.toplevel_buttons:
674
button = self.toplevel_buttons[handle]
675
self.set_title(button, title)
676
677
def set_all_rectangles(self):
678
child = self.get_first_child()
679
while child is not None and self.wl_surface:
680
if isinstance(child, WindowButton):
681
x, y, w, h = get_widget_rect(child)
682
if w > 0 and h > 0:
683
child.window_id.set_rectangle(self.wl_surface, x, y, w, h)
684
685
child = child.get_next_sibling()
686
687
def on_button_click(self, button: WindowButton):
688
# Set a rectangle for animation
689
button.window_id.set_rectangle(self.wl_surface, *get_widget_rect(button))
690
if button.window_state.minimised:
691
button.window_id.activate(self.wl_seat)
692
else:
693
if button.window_state.focused:
694
button.window_id.set_minimized()
695
else:
696
button.window_id.activate(self.wl_seat)
697
698
def foreign_toplevel_state(self, handle, states):
699
if handle in self.toplevel_buttons:
700
state_info = WindowState.from_state_array(states)
701
button = self.toplevel_buttons[handle]
702
button.window_state = state_info
703
if state_info.focused:
704
button.set_active(True)
705
else:
706
self.initial_button.set_active(True)
707
708
def foreign_toplevel_refresh(self):
709
for button in self.toplevel_buttons.values():
710
self.remove(button)
711
712
self.toplevel_buttons.clear()
713
self.toplevel_buttons_by_wf_id.clear()
714
715
def set_app_id(self, button, app_id):
716
button.app_id = app_id
717
button.set_icon_from_app_id(app_id)
718
app_ids = app_id.split()
719
for app_id in app_ids:
720
if app_id.startswith("wf-ipc-"):
721
self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button
722
723
def foreign_toplevel_app_id(self, handle, app_id):
724
handle.app_id = app_id
725
if handle in self.toplevel_buttons:
726
button = self.toplevel_buttons[handle]
727
self.set_app_id(button, app_id)
728
729
def foreign_toplevel_closed(self, handle):
730
if handle not in self.toplevel_buttons:
731
return
732
button: WindowButton = self.toplevel_buttons[handle]
733
wf_id = button.wf_ipc_id
734
if handle in self.toplevel_buttons:
735
self.remove(self.toplevel_buttons[handle])
736
self.toplevel_buttons.pop(handle)
737
if wf_id in self.toplevel_buttons_by_wf_id:
738
self.toplevel_buttons_by_wf_id.pop(wf_id)
739
740
self.set_all_rectangles()
741
742
def make_context_menu(self):
743
menu = Gio.Menu()
744
menu.append(_("Window list _options"), "applet.options")
745
context_menu = Gtk.PopoverMenu.new_from_model(menu)
746
context_menu.set_has_arrow(False)
747
context_menu.set_parent(self)
748
context_menu.set_halign(Gtk.Align.START)
749
context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
750
return context_menu
751
752
def show_context_menu(self, gesture, n_presses, x, y):
753
rect = Gdk.Rectangle()
754
rect.x = int(x)
755
rect.y = int(y)
756
rect.width = 1
757
rect.height = 1
758
759
self.context_menu.set_pointing_to(rect)
760
self.context_menu.popup()
761
762
def show_options(self, _0=None, _1=None):
763
if self.options_window is None:
764
self.options_window = WindowListOptions()
765
self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
766
self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
767
self.options_window.workspace_filter_checkbutton.set_active(self.show_only_this_wf_workspace)
768
self.options_window.workspace_filter_checkbutton.connect("toggled", self.update_workspace_filter)
769
770
def reset_window(*args):
771
self.options_window = None
772
773
self.options_window.connect("close-request", reset_window)
774
self.options_window.present()
775
776
def remove_workspace_filtering(self):
777
for button in self.toplevel_buttons.values():
778
if button.get_parent() is None:
779
self.append(button)
780
781
def update_workspace_filter(self, checkbutton):
782
self.show_only_this_wf_workspace = checkbutton.get_active()
783
if checkbutton.get_active():
784
self.filter_to_wf_workspace()
785
else:
786
self.remove_workspace_filtering()
787
788
def update_button_options(self, adjustment):
789
self.window_button_options.max_width = adjustment.get_value()
790
child: Gtk.Widget = self.get_first_child()
791
while child:
792
child.queue_allocate()
793
child.queue_resize()
794
child.queue_draw()
795
child = child.get_next_sibling()
796
797
self.emit("config-changed")
798
799
def get_config(self):
800
return {
801
"max_button_width": self.window_button_options.max_width,
802
"show_only_this_wf_workspace": self.show_only_this_wf_workspace,
803
"show_only_this_output": self.show_only_this_output,
804
}
805
806
def make_draggable(self):
807
for button in self.toplevel_buttons.values():
808
button.remove_controller(button.drag_source)
809
button.set_sensitive(False)
810
811
def restore_drag(self):
812
for button in self.toplevel_buttons.values():
813
button.add_controller(button.drag_source)
814
button.set_sensitive(True)
815