__init__.py

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