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.24 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
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 = Gio.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
420
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
421
super().__init__(orientation=orientation, config=config)
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
child = self.get_first_child()
540
while child:
541
allocation = child.get_allocation()
542
child_x, child_y = self.translate_coordinates(self, 0, 0)
543
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
544
midpoint = child_x + allocation.width / 2
545
if x < midpoint:
546
button.insert_before(self, child)
547
break
548
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
549
midpoint = child_y + allocation.height / 2
550
if y < midpoint:
551
button.insert_before(self, child)
552
break
553
child = child.get_next_sibling()
554
else:
555
self.append(button)
556
button.show()
557
558
self.set_all_rectangles()
559
return True
560
561
def filter_to_wf_workspace(self):
562
if not self.show_only_this_wf_workspace:
563
return
564
565
output = self.get_wf_output_by_name(self.get_root().monitor_name)
566
if not output:
567
return
568
for wf_id, button in self.toplevel_buttons_by_wf_id.items():
569
view = self.wf_socket.get_view(wf_id)
570
mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
571
mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
572
output_width = output["geometry"]["width"]
573
output_height = output["geometry"]["height"]
574
if 0 <= mid_x < output_width and 0 <= mid_y < output_height and button.output == self.my_output:
575
# It is in this workspace; keep it
576
if button.get_parent() is None:
577
self.append(button)
578
else:
579
# Remove it from this window list
580
if button.get_parent() is self:
581
self.remove(button)
582
583
def get_wl_resources(self, widget):
584
if self.wf_socket is not None:
585
self.filter_to_wf_workspace()
586
587
if self.wl_shm:
588
return
589
590
self.my_output = None
591
self.wl_seat = self.get_root().get_application().wl_seat
592
self.output = None
593
self.wl_shm = self.get_root().get_application().wl_shm
594
self.screencopy_manager = self.get_root().get_application().screencopy_manager
595
self.live_preview_output_name = None
596
597
def set_live_preview_output(self, output):
598
self.output = output
599
600
def get_live_preview_output(self):
601
return self.output
602
603
def wl_output_enter(self, output, name):
604
self.set_live_preview_output(None)
605
if name.startswith("live-preview"):
606
self.set_live_preview_output(output)
607
return
608
if name.startswith("live-preview"):
609
return
610
if not self.my_output and name == self.get_root().monitor_name:
611
self.wl_surface = None
612
if self.get_root().get_native().get_surface():
613
wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
614
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
615
self.get_root().get_native().get_surface().__gpointer__, None)))
616
self.wl_surface = WlSurface()
617
self.wl_surface._ptr = wl_surface_ptr
618
self.my_output = output
619
else:
620
toplevel_buttons = self.toplevel_buttons.copy()
621
for handle in toplevel_buttons:
622
button = self.toplevel_buttons[handle]
623
if output != button.output:
624
self.foreign_toplevel_closed(handle)
625
626
def foreign_toplevel_output_enter(self, handle, output):
627
if self.show_only_this_output and (not hasattr(output, "name") or output.name != self.get_root().monitor_name):
628
return
629
630
button = WindowButton(handle, handle.title, self)
631
button.icon.set_pixel_size(self.icon_size)
632
button.output = output
633
self.set_title(button, handle.title)
634
button.set_group(self.initial_button)
635
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options, button))
636
button.connect("clicked", self.on_button_click)
637
self.set_app_id(button, handle.app_id)
638
self.toplevel_buttons[handle] = button
639
if handle.app_id.rsplit(maxsplit=1)[-1].startswith("wf-ipc-"):
640
wf_output = self.get_wf_output_by_name(self.get_root().monitor_name)
641
wf_id = int(handle.app_id.rsplit(maxsplit=1)[-1].removeprefix("wf-ipc-"))
642
view = self.wf_socket.get_view(wf_id)
643
mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
644
mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
645
output_width = wf_output["geometry"]["width"]
646
output_height = wf_output["geometry"]["height"]
647
if 0 <= mid_x < output_width and 0 <= mid_y < output_height and button.output == self.my_output:
648
# It is in this workspace; keep it
649
if button.get_parent() is None:
650
self.append(button)
651
else:
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