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 • 31.25 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, 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
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 = Gio.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
419
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
420
super().__init__(orientation=orientation, config=config)
421
if config is None:
422
config = {}
423
424
self.set_homogeneous(True)
425
self.window_button_options = WindowButtonOptions(config.get("max_button_width", 256))
426
self.show_only_this_wf_workspace = config.get("show_only_this_wf_workspace", True)
427
self.show_only_this_output = config.get("show_only_this_output", True)
428
self.icon_size = config.get("icon_size", 24)
429
430
self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {}
431
self.toplevel_buttons_by_wf_id: dict[int, WindowButton] = {}
432
# This button doesn't belong to any window but is used for the button group and to be
433
# selected when no window is focused
434
self.initial_button = Gtk.ToggleButton()
435
436
self.my_output = None
437
self.wl_surface = None
438
self.compositor = None
439
self.wl_seat = None
440
self.output = None
441
self.wl_shm = None
442
#self.manager = None
443
self.screencopy_manager = None
444
self.live_preview_output_name = None
445
446
self.context_menu = self.make_context_menu()
447
panorama_panel.track_popover(self.context_menu)
448
449
right_click_controller = Gtk.GestureClick()
450
right_click_controller.set_button(3)
451
right_click_controller.connect("pressed", self.show_context_menu)
452
453
self.add_controller(right_click_controller)
454
455
action_group = Gio.SimpleActionGroup()
456
options_action = Gio.SimpleAction.new("options", None)
457
options_action.connect("activate", self.show_options)
458
action_group.add_action(options_action)
459
self.insert_action_group("applet", action_group)
460
# Wait for the widget to be in a layer-shell window before doing this
461
self.connect("realize", self.get_wl_resources)
462
463
self.options_window = None
464
465
# Support button reordering
466
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
467
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
468
self.drop_target.connect("drop", self.drop_button)
469
470
self.add_controller(self.drop_target)
471
472
# Make a Wayfire socket for workspace handling
473
self.live_preview_tooltips = False
474
try:
475
import wayfire
476
from wayfire.core.template import get_msg_template
477
self.wf_socket = wayfire.WayfireSocket()
478
self.get_msg_template = get_msg_template
479
if "live_previews/request_stream" in self.wf_socket.list_methods():
480
self.live_preview_tooltips = True
481
print("WFWindowList: Live Preview Tooltips: enabled")
482
else:
483
print("WFWindowList: Live Preview Tooltips: disabled")
484
print("- Live preview tooltips requires wayfire wf-live-previews plugin. Is it enabled?")
485
self.wf_socket.watch(["view-workspace-changed", "wset-workspace-changed"])
486
fd = self.wf_socket.client.fileno()
487
GLib.io_add_watch(GLib.IOChannel.unix_new(fd), GLib.IO_IN, self.on_wf_event, priority=GLib.PRIORITY_HIGH)
488
except:
489
# Wayfire raises Exception itself, so it cannot be narrowed down
490
self.wf_socket = None
491
492
def get_wf_output_by_name(self, name):
493
if not self.wf_socket:
494
return None
495
for output in self.wf_socket.list_outputs():
496
if output["name"] == name:
497
return output
498
return None
499
500
def on_wf_event(self, source, condition):
501
if condition & GLib.IO_IN:
502
try:
503
message = self.wf_socket.read_next_event()
504
event = message.get("event")
505
match event:
506
case "view-workspace-changed":
507
view = message.get("view", {})
508
if view["output-name"] == self.get_root().monitor_name:
509
output = self.get_wf_output_by_name(view["output-name"])
510
current_workspace = output["workspace"]["x"], output["workspace"]["y"]
511
button = self.toplevel_buttons_by_wf_id[view["id"]]
512
if not self.show_only_this_wf_workspace or (message["to"]["x"], message["to"]["y"]) == current_workspace:
513
if button.get_parent() is None and button.output == self.my_output:
514
self.append(button)
515
else:
516
if button.get_parent() is self:
517
# Remove out-of-workspace window
518
self.remove(button)
519
case "wset-workspace-changed":
520
output_name = self.get_root().monitor_name
521
if message["wset-data"]["output-name"] == output_name:
522
# It has changed on this monitor; refresh the window list
523
self.filter_to_wf_workspace()
524
525
except Exception as e:
526
print("Error reading Wayfire event:", e)
527
return True
528
529
def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
530
button: WindowButton = self.get_root().get_application().drags.pop(value)
531
if button.get_parent() is not self:
532
# Prevent dropping a button from another window list
533
return False
534
535
self.remove(button)
536
# Find the position where to insert the applet
537
# Probably we could use the assumption that buttons are homogeneous here for efficiency
538
child = self.get_first_child()
539
while child:
540
allocation = child.get_allocation()
541
child_x, child_y = self.translate_coordinates(self, 0, 0)
542
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
543
midpoint = child_x + allocation.width / 2
544
if x < midpoint:
545
button.insert_before(self, child)
546
break
547
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
548
midpoint = child_y + allocation.height / 2
549
if y < midpoint:
550
button.insert_before(self, child)
551
break
552
child = child.get_next_sibling()
553
else:
554
self.append(button)
555
button.show()
556
557
self.set_all_rectangles()
558
return True
559
560
def filter_to_wf_workspace(self):
561
if not self.show_only_this_wf_workspace:
562
return
563
564
output = self.get_wf_output_by_name(self.get_root().monitor_name)
565
if not output:
566
return
567
for wf_id, button in self.toplevel_buttons_by_wf_id.items():
568
view = self.wf_socket.get_view(wf_id)
569
mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
570
mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
571
output_width = output["geometry"]["width"]
572
output_height = output["geometry"]["height"]
573
if 0 <= mid_x < output_width and 0 <= mid_y < output_height and button.output == self.my_output:
574
# It is in this workspace; keep it
575
if button.get_parent() is None:
576
self.append(button)
577
else:
578
# Remove it from this window list
579
if button.get_parent() is self:
580
self.remove(button)
581
582
def get_wl_resources(self, widget):
583
if self.wf_socket is not None:
584
self.filter_to_wf_workspace()
585
586
if self.wl_shm:
587
return
588
589
self.my_output = None
590
self.wl_seat = self.get_root().get_application().wl_seat
591
self.output = None
592
self.wl_shm = self.get_root().get_application().wl_shm
593
self.screencopy_manager = self.get_root().get_application().screencopy_manager
594
self.live_preview_output_name = None
595
596
def set_live_preview_output(self, output):
597
self.output = output
598
599
def get_live_preview_output(self):
600
return self.output
601
602
def wl_output_enter(self, output, name):
603
self.set_live_preview_output(None)
604
if name.startswith("live-preview"):
605
self.set_live_preview_output(output)
606
return
607
if name.startswith("live-preview"):
608
return
609
if not self.my_output and name == self.get_root().monitor_name:
610
self.wl_surface = None
611
if self.get_root().get_native().get_surface():
612
wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
613
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
614
self.get_root().get_native().get_surface().__gpointer__, None)))
615
self.wl_surface = WlSurface()
616
self.wl_surface._ptr = wl_surface_ptr
617
self.my_output = output
618
else:
619
toplevel_buttons = self.toplevel_buttons.copy()
620
for handle in toplevel_buttons:
621
button = self.toplevel_buttons[handle]
622
if output != button.output:
623
self.foreign_toplevel_closed(handle)
624
625
def foreign_toplevel_output_enter(self, handle, output):
626
if self.show_only_this_output and (not hasattr(output, "name") or output.name != self.get_root().monitor_name):
627
return
628
629
button = WindowButton(handle, handle.title, self)
630
button.icon.set_pixel_size(self.icon_size)
631
button.output = output
632
self.set_title(button, handle.title)
633
button.set_group(self.initial_button)
634
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options, button))
635
button.connect("clicked", self.on_button_click)
636
self.set_app_id(button, handle.app_id)
637
if handle.app_id.rsplit(maxsplit=1)[-1].startswith("wf-ipc-"):
638
wf_output = self.get_wf_output_by_name(self.get_root().monitor_name)
639
wf_id = int(handle.app_id.rsplit(maxsplit=1)[-1].removeprefix("wf-ipc-"))
640
view = self.wf_socket.get_view(wf_id)
641
mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
642
mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
643
output_width = wf_output["geometry"]["width"]
644
output_height = wf_output["geometry"]["height"]
645
if 0 <= mid_x < output_width and 0 <= mid_y < output_height and button.output == self.my_output:
646
# It is in this workspace; keep it
647
if button.get_parent() is None:
648
self.toplevel_buttons[handle] = button
649
self.append(button)
650
else:
651
self.toplevel_buttons[handle] = button
652
self.append(button)
653
654
def foreign_toplevel_output_leave(self, handle, output):
655
if handle in self.toplevel_buttons:
656
button = self.toplevel_buttons[handle]
657
if button.get_parent() == self:
658
button.output = None
659
self.remove(button)
660
self.set_all_rectangles()
661
662
def set_title(self, button, title):
663
button.window_title = title
664
if not self.live_preview_tooltips:
665
button.set_tooltip_text(title)
666
667
def foreign_toplevel_title(self, handle, title):
668
handle.title = title
669
if handle in self.toplevel_buttons:
670
button = self.toplevel_buttons[handle]
671
self.set_title(button, title)
672
673
def set_all_rectangles(self):
674
child = self.get_first_child()
675
while child is not None and self.wl_surface:
676
if isinstance(child, WindowButton):
677
x, y, w, h = get_widget_rect(child)
678
if w > 0 and h > 0:
679
child.window_id.set_rectangle(self.wl_surface, x, y, w, h)
680
681
child = child.get_next_sibling()
682
683
def on_button_click(self, button: WindowButton):
684
# Set a rectangle for animation
685
button.window_id.set_rectangle(self.wl_surface, *get_widget_rect(button))
686
if button.window_state.minimised:
687
button.window_id.activate(self.wl_seat)
688
else:
689
if button.window_state.focused:
690
button.window_id.set_minimized()
691
else:
692
button.window_id.activate(self.wl_seat)
693
694
def foreign_toplevel_state(self, handle, states):
695
if handle in self.toplevel_buttons:
696
state_info = WindowState.from_state_array(states)
697
button = self.toplevel_buttons[handle]
698
button.window_state = state_info
699
if state_info.focused:
700
button.set_active(True)
701
else:
702
self.initial_button.set_active(True)
703
704
def foreign_toplevel_refresh(self):
705
for button in self.toplevel_buttons.values():
706
self.remove(button)
707
708
self.toplevel_buttons.clear()
709
self.toplevel_buttons_by_wf_id.clear()
710
711
def set_app_id(self, button, app_id):
712
button.app_id = app_id
713
button.set_icon_from_app_id(app_id)
714
app_ids = app_id.split()
715
for app_id in app_ids:
716
if app_id.startswith("wf-ipc-"):
717
self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button
718
719
def foreign_toplevel_app_id(self, handle, app_id):
720
handle.app_id = app_id
721
if handle in self.toplevel_buttons:
722
button = self.toplevel_buttons[handle]
723
self.set_app_id(button, app_id)
724
725
def foreign_toplevel_closed(self, handle):
726
if handle not in self.toplevel_buttons:
727
return
728
button: WindowButton = self.toplevel_buttons[handle]
729
wf_id = button.wf_ipc_id
730
if handle in self.toplevel_buttons:
731
self.remove(self.toplevel_buttons[handle])
732
self.toplevel_buttons.pop(handle)
733
if wf_id in self.toplevel_buttons_by_wf_id:
734
self.toplevel_buttons_by_wf_id.pop(wf_id)
735
736
self.set_all_rectangles()
737
738
def make_context_menu(self):
739
menu = Gio.Menu()
740
menu.append(_("Window list _options"), "applet.options")
741
context_menu = Gtk.PopoverMenu.new_from_model(menu)
742
context_menu.set_has_arrow(False)
743
context_menu.set_parent(self)
744
context_menu.set_halign(Gtk.Align.START)
745
context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
746
return context_menu
747
748
def show_context_menu(self, gesture, n_presses, x, y):
749
rect = Gdk.Rectangle()
750
rect.x = int(x)
751
rect.y = int(y)
752
rect.width = 1
753
rect.height = 1
754
755
self.context_menu.set_pointing_to(rect)
756
self.context_menu.popup()
757
758
def show_options(self, _0=None, _1=None):
759
if self.options_window is None:
760
self.options_window = WindowListOptions()
761
self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
762
self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
763
self.options_window.workspace_filter_checkbutton.set_active(self.show_only_this_wf_workspace)
764
self.options_window.workspace_filter_checkbutton.connect("toggled", self.update_workspace_filter)
765
766
def reset_window(*args):
767
self.options_window = None
768
769
self.options_window.connect("close-request", reset_window)
770
self.options_window.present()
771
772
def remove_workspace_filtering(self):
773
for button in self.toplevel_buttons.values():
774
if button.get_parent() is None:
775
self.append(button)
776
777
def update_workspace_filter(self, checkbutton):
778
self.show_only_this_wf_workspace = checkbutton.get_active()
779
if checkbutton.get_active():
780
self.filter_to_wf_workspace()
781
else:
782
self.remove_workspace_filtering()
783
784
def update_button_options(self, adjustment):
785
self.window_button_options.max_width = adjustment.get_value()
786
child: Gtk.Widget = self.get_first_child()
787
while child:
788
child.queue_allocate()
789
child.queue_resize()
790
child.queue_draw()
791
child = child.get_next_sibling()
792
793
self.emit("config-changed")
794
795
def get_config(self):
796
return {
797
"max_button_width": self.window_button_options.max_width,
798
"show_only_this_wf_workspace": self.show_only_this_wf_workspace,
799
"show_only_this_output": self.show_only_this_output,
800
}
801
802
def make_draggable(self):
803
for button in self.toplevel_buttons.values():
804
button.remove_controller(button.drag_source)
805
button.set_sensitive(False)
806
807
def restore_drag(self):
808
for button in self.toplevel_buttons.values():
809
button.add_controller(button.drag_source)
810
button.set_sensitive(True)
811