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