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 • 24.88 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 locale
24
import typing
25
from pathlib import Path
26
from pywayland.client import Display, EventQueue
27
from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlCompositor, WlOutput
28
from pywayland.protocol.wayland.wl_output import WlOutputProxy
29
from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import (
30
ZwlrForeignToplevelManagerV1,
31
ZwlrForeignToplevelHandleV1
32
)
33
import panorama_panel
34
35
import gi
36
37
gi.require_version("Gtk", "4.0")
38
gi.require_version("GdkWayland", "4.0")
39
40
from gi.repository import Gtk, GLib, Gtk4LayerShell, Gio, Gdk, Pango, GObject
41
42
43
module_directory = Path(__file__).resolve().parent
44
45
locale.bindtextdomain("panorama-window-list", module_directory / "locale")
46
_ = lambda x: locale.dgettext("panorama-window-list", x)
47
48
49
import ctypes
50
from cffi import FFI
51
ffi = FFI()
52
ffi.cdef("""
53
void * gdk_wayland_display_get_wl_display (void * display);
54
void * gdk_wayland_surface_get_wl_surface (void * surface);
55
void * gdk_wayland_monitor_get_wl_output (void * monitor);
56
""")
57
gtk = ffi.dlopen("libgtk-4.so.1")
58
59
60
@Gtk.Template(filename=str(module_directory / "panorama-window-list-options.ui"))
61
class WindowListOptions(Gtk.Window):
62
__gtype_name__ = "WindowListOptions"
63
button_width_adjustment: Gtk.Adjustment = Gtk.Template.Child()
64
65
def __init__(self, **kwargs):
66
super().__init__(**kwargs)
67
68
self.connect("close-request", lambda *args: self.destroy())
69
70
71
def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]:
72
if len(array) % size:
73
raise ValueError(f"The byte string's length must be a multiple of {size}")
74
75
values: list[int] = []
76
for i in range(0, len(array), size):
77
values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder))
78
79
return values
80
81
82
def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]:
83
width = int(widget.get_width())
84
height = int(widget.get_height())
85
86
toplevel = widget.get_root()
87
if not toplevel:
88
return None, None, width, height
89
90
x, y = widget.translate_coordinates(toplevel, 0, 0)
91
x = int(x)
92
y = int(y)
93
94
return x, y, width, height
95
96
97
@dataclasses.dataclass
98
class WindowState:
99
minimised: bool
100
maximised: bool
101
fullscreen: bool
102
focused: bool
103
104
@classmethod
105
def from_state_array(cls, array: bytes):
106
values = split_bytes_into_ints(array)
107
instance = cls(False, False, False, False)
108
for value in values:
109
match value:
110
case 0:
111
instance.maximised = True
112
case 1:
113
instance.minimised = True
114
case 2:
115
instance.focused = True
116
case 3:
117
instance.fullscreen = True
118
119
return instance
120
121
122
from gi.repository import Gtk, Gdk
123
124
class WindowButtonOptions:
125
def __init__(self, max_width: int):
126
self.max_width = max_width
127
128
class WindowButtonLayoutManager(Gtk.LayoutManager):
129
def __init__(self, options: WindowButtonOptions, **kwargs):
130
super().__init__(**kwargs)
131
self.options = options
132
133
def do_measure(self, widget, orientation, for_size):
134
child = widget.get_first_child()
135
if child is None:
136
return 0, 0, 0, 0
137
138
if orientation == Gtk.Orientation.HORIZONTAL:
139
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size)
140
width = self.options.max_width
141
return min_width, width, min_height, nat_height
142
else:
143
min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size)
144
return min_height, nat_height, 0, 0
145
146
def do_allocate(self, widget, width, height, baseline):
147
child = widget.get_first_child()
148
if child is None:
149
return
150
alloc_width = min(width, self.options.max_width)
151
alloc = Gdk.Rectangle()
152
alloc.x = 0
153
alloc.y = 0
154
alloc.width = alloc_width
155
alloc.height = height
156
child.allocate(alloc.width, alloc.height, baseline)
157
158
159
class WindowButton(Gtk.ToggleButton):
160
def __init__(self, window_id, window_title, **kwargs):
161
super().__init__(**kwargs)
162
163
self.window_id: ZwlrForeignToplevelHandleV1 = window_id
164
self.wf_ipc_id: typing.Optional[int] = None
165
self.set_has_frame(False)
166
self.label = Gtk.Label()
167
self.icon = Gtk.Image.new_from_icon_name("application-x-executable")
168
box = Gtk.Box()
169
box.append(self.icon)
170
box.append(self.label)
171
self.set_child(box)
172
173
self.window_title = window_title
174
self.window_state = WindowState(False, False, False, False)
175
176
self.label.set_ellipsize(Pango.EllipsizeMode.END)
177
self.set_hexpand(True)
178
self.set_vexpand(True)
179
180
self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE)
181
self.drag_source.connect("prepare", self.provide_drag_data)
182
self.drag_source.connect("drag-begin", self.drag_begin)
183
self.drag_source.connect("drag-cancel", self.drag_cancel)
184
185
self.add_controller(self.drag_source)
186
187
self.menu = Gio.Menu()
188
# TODO: toggle the labels when needed
189
self.minimise_item = Gio.MenuItem.new(_("_Minimise"), "button.minimise")
190
self.maximise_item = Gio.MenuItem.new(_("Ma_ximise"), "button.maximise")
191
self.menu.append_item(self.minimise_item)
192
self.menu.append_item(self.maximise_item)
193
self.menu.append(_("_Close"), "button.close")
194
self.menu.append(_("Window list _options"), "applet.options")
195
self.popover_menu = Gtk.PopoverMenu.new_from_model(self.menu)
196
self.popover_menu.set_parent(self)
197
self.popover_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
198
self.popover_menu.set_has_arrow(False)
199
self.popover_menu.set_halign(Gtk.Align.END)
200
201
self.right_click_controller = Gtk.GestureClick(button=3)
202
self.right_click_controller.connect("pressed", self.show_menu)
203
self.add_controller(self.right_click_controller)
204
205
self.action_group = Gio.SimpleActionGroup()
206
close_action = Gio.SimpleAction.new("close")
207
close_action.connect("activate", self.close_associated)
208
self.action_group.insert(close_action)
209
minimise_action = Gio.SimpleAction.new("minimise")
210
minimise_action.connect("activate", self.minimise_associated)
211
self.action_group.insert(minimise_action)
212
maximise_action = Gio.SimpleAction.new("maximise")
213
maximise_action.connect("activate", self.maximise_associated)
214
self.action_group.insert(maximise_action)
215
216
self.insert_action_group("button", self.action_group)
217
218
self.middle_click_controller = Gtk.GestureClick(button=2)
219
self.middle_click_controller.connect("released", self.close_associated)
220
self.add_controller(self.middle_click_controller)
221
222
def show_menu(self, gesture, n_presses, x, y):
223
rect = Gdk.Rectangle()
224
rect.x = int(x)
225
rect.y = int(y)
226
rect.width = 1
227
rect.height = 1
228
self.popover_menu.popup()
229
230
def close_associated(self, *args):
231
self.window_id.close()
232
233
def minimise_associated(self, action, *args):
234
if self.window_state.minimised:
235
self.window_id.unset_minimized()
236
else:
237
self.window_id.set_minimized()
238
239
def maximise_associated(self, action, *args):
240
if self.window_state.maximised:
241
self.window_id.unset_maximized()
242
else:
243
self.window_id.set_maximized()
244
245
def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float):
246
app = self.get_root().get_application()
247
app.drags[id(self)] = self
248
value = GObject.Value()
249
value.init(GObject.TYPE_UINT64)
250
value.set_uint64(id(self))
251
return Gdk.ContentProvider.new_for_value(value)
252
253
def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag):
254
paintable = Gtk.WidgetPaintable.new(self).get_current_image()
255
source.set_icon(paintable, 0, 0)
256
self.hide()
257
258
def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason):
259
self.show()
260
return False
261
262
@property
263
def window_title(self):
264
return self.label.get_text()
265
266
@window_title.setter
267
def window_title(self, value):
268
self.label.set_text(value)
269
270
def set_icon_from_app_id(self, app_id):
271
app_ids = app_id.split()
272
273
# If on Wayfire, find the IPC ID
274
for app_id in app_ids:
275
if app_id.startswith("wf-ipc-"):
276
self.wf_ipc_id = int(app_id.removeprefix("wf-ipc-"))
277
break
278
279
# Try getting an icon from the correct theme
280
icon_theme = Gtk.IconTheme.get_for_display(self.get_display())
281
282
for app_id in app_ids:
283
if icon_theme.has_icon(app_id):
284
self.icon.set_from_icon_name(app_id)
285
return
286
287
# If that doesn't work, try getting one from .desktop files
288
for app_id in app_ids:
289
try:
290
desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop")
291
if desktop_file:
292
self.icon.set_from_gicon(desktop_file.get_icon())
293
return
294
except TypeError:
295
# Due to a bug, the constructor may sometimes return C NULL
296
pass
297
298
299
class WFWindowList(panorama_panel.Applet):
300
name = _("Wayfire window list")
301
description = _("Traditional window list (for Wayfire and other wlroots compositors)")
302
303
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
304
super().__init__(orientation=orientation, config=config)
305
if config is None:
306
config = {}
307
308
self.set_homogeneous(True)
309
self.window_button_options = WindowButtonOptions(config.get("max_button_width", 256))
310
311
self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {}
312
self.toplevel_buttons_by_wf_id: dict[int, WindowButton] = {}
313
# This button doesn't belong to any window but is used for the button group and to be
314
# selected when no window is focused
315
self.initial_button = Gtk.ToggleButton()
316
317
self.display = None
318
self.my_output = None
319
self.wl_surface_ptr = None
320
self.registry = None
321
self.compositor = None
322
self.seat = None
323
324
self.context_menu = self.make_context_menu()
325
panorama_panel.track_popover(self.context_menu)
326
327
right_click_controller = Gtk.GestureClick()
328
right_click_controller.set_button(3)
329
right_click_controller.connect("pressed", self.show_context_menu)
330
331
self.add_controller(right_click_controller)
332
333
action_group = Gio.SimpleActionGroup()
334
options_action = Gio.SimpleAction.new("options", None)
335
options_action.connect("activate", self.show_options)
336
action_group.add_action(options_action)
337
self.insert_action_group("applet", action_group)
338
# Wait for the widget to be in a layer-shell window before doing this
339
self.connect("realize", lambda *args: self.get_wl_resources())
340
341
self.options_window = None
342
343
# Support button reordering
344
self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
345
self.drop_target.set_gtypes([GObject.TYPE_UINT64])
346
self.drop_target.connect("drop", self.drop_button)
347
348
self.add_controller(self.drop_target)
349
350
# Make a Wayfire socket for workspace handling
351
try:
352
import wayfire
353
self.wf_socket = wayfire.WayfireSocket()
354
self.wf_socket.watch()
355
fd = self.wf_socket.client.fileno()
356
GLib.io_add_watch(GLib.IOChannel.unix_new(fd), GLib.IO_IN, self.on_wf_event, priority=GLib.PRIORITY_HIGH)
357
except:
358
# Wayfire raises Exception itself, so it cannot be narrowed down
359
self.wf_socket = None
360
361
def on_wf_event(self, source, condition):
362
if condition & GLib.IO_IN:
363
try:
364
message = self.wf_socket.read_next_event()
365
event = message.get("event")
366
match event:
367
case "view-workspace-changed":
368
view = message.get("view", {})
369
output = self.wf_socket.get_output(self.get_root().monitor_index + 1)
370
current_workspace = output["workspace"]["x"], output["workspace"]["y"]
371
if (message["to"]["x"], message["to"]["y"]) == current_workspace:
372
if self.toplevel_buttons_by_wf_id[view["id"]].get_parent() is None:
373
self.append(self.toplevel_buttons_by_wf_id[view["id"]])
374
else:
375
if self.toplevel_buttons_by_wf_id[view["id"]].get_parent() is self:
376
# Remove out-of-workspace window
377
self.remove(self.toplevel_buttons_by_wf_id[view["id"]])
378
case "wset-workspace-changed":
379
output_id = self.get_root().monitor_index + 1
380
if message["wset-data"]["output-id"] == output_id:
381
# It has changed on this monitor; refresh the window list
382
self.filter_to_wf_workspace()
383
384
except Exception as e:
385
print("Error reading Wayfire event:", e)
386
return True
387
388
def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
389
button: WindowButton = self.get_root().get_application().drags.pop(value)
390
if button.get_parent() is not self:
391
# Prevent dropping a button from another window list
392
return False
393
394
self.remove(button)
395
# Find the position where to insert the applet
396
# Probably we could use the assumption that buttons are homogeneous here for efficiency
397
child = self.get_first_child()
398
while child:
399
allocation = child.get_allocation()
400
child_x, child_y = self.translate_coordinates(self, 0, 0)
401
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
402
midpoint = child_x + allocation.width / 2
403
if x < midpoint:
404
button.insert_before(self, child)
405
break
406
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
407
midpoint = child_y + allocation.height / 2
408
if y < midpoint:
409
button.insert_before(self, child)
410
break
411
child = child.get_next_sibling()
412
else:
413
self.append(button)
414
button.show()
415
416
self.set_all_rectangles()
417
return True
418
419
def filter_to_wf_workspace(self):
420
output = self.wf_socket.get_output(self.get_root().monitor_index + 1)
421
for wf_id, button in self.toplevel_buttons_by_wf_id.items():
422
view = self.wf_socket.get_view(wf_id)
423
mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
424
mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
425
output_width = output["geometry"]["width"]
426
output_height = output["geometry"]["height"]
427
if 0 <= mid_x < output_width and 0 <= mid_y < output_height:
428
# It is in this workspace; keep it
429
if button.get_parent() is None:
430
self.append(button)
431
else:
432
# Remove it from this window list
433
if button.get_parent() is self:
434
self.remove(button)
435
436
def get_wl_resources(self):
437
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
438
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
439
440
self.display = Display()
441
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
442
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None)))
443
self.display._ptr = wl_display_ptr
444
self.event_queue = EventQueue(self.display)
445
446
# Intentionally commented: the display is already connected by GTK
447
# self.display.connect()
448
449
my_monitor = Gtk4LayerShell.get_monitor(self.get_root())
450
451
# Iterate through monitors and get their Wayland output (wl_output)
452
# This is a hack to ensure output_enter/leave is called for toplevels
453
for monitor in self.get_root().get_native().get_display().get_monitors():
454
wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
455
if wl_output:
456
print("Create proxy")
457
output_proxy = WlOutputProxy(wl_output, self.display)
458
output_proxy.interface.registry[output_proxy._ptr] = output_proxy
459
460
if monitor == my_monitor:
461
self.my_output = output_proxy
462
# End hack
463
464
self.registry = self.display.get_registry()
465
self.registry.dispatcher["global"] = self.on_global
466
self.display.roundtrip()
467
fd = self.display.get_fd()
468
GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event)
469
470
if self.wf_socket is not None:
471
self.filter_to_wf_workspace()
472
473
def on_display_event(self, source, condition):
474
if condition & GLib.IO_IN:
475
self.display.dispatch(queue=self.event_queue)
476
return True
477
478
def on_global(self, registry, name, interface, version):
479
if interface == "zwlr_foreign_toplevel_manager_v1":
480
self.print_log("Interface registered")
481
self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version)
482
self.manager.dispatcher["toplevel"] = self.on_new_toplevel
483
self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
484
self.display.roundtrip()
485
self.display.flush()
486
elif interface == "wl_seat":
487
self.print_log("Seat found")
488
self.seat = registry.bind(name, WlSeat, version)
489
elif interface == "wl_compositor":
490
self.compositor = registry.bind(name, WlCompositor, version)
491
self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
492
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
493
self.get_root().get_native().get_surface().__gpointer__, None)))
494
495
def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
496
handle: ZwlrForeignToplevelHandleV1):
497
handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title)
498
handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id)
499
handle.dispatcher["output_enter"] = self.on_output_entered
500
handle.dispatcher["output_leave"] = self.on_output_left
501
handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states)
502
handle.dispatcher["closed"] = lambda h: self.on_closed(h)
503
504
def on_output_entered(self, handle, output):
505
# TODO: make this configurable
506
# TODO: on wayfire, append/remove buttons when the workspace changes
507
if output != self.my_output:
508
return
509
if handle in self.toplevel_buttons:
510
button = self.toplevel_buttons[handle]
511
self.append(button)
512
self.set_all_rectangles()
513
514
def on_output_left(self, handle, output):
515
if output != self.my_output:
516
return
517
if handle in self.toplevel_buttons:
518
button = self.toplevel_buttons[handle]
519
self.remove(button)
520
self.set_all_rectangles()
521
522
def on_title_changed(self, handle, title):
523
if handle not in self.toplevel_buttons:
524
button = WindowButton(handle, title)
525
button.set_group(self.initial_button)
526
button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options))
527
button.connect("clicked", self.on_button_click)
528
self.toplevel_buttons[handle] = button
529
else:
530
button = self.toplevel_buttons[handle]
531
button.window_title = title
532
533
def set_all_rectangles(self):
534
child = self.get_first_child()
535
while child is not None:
536
if isinstance(child, WindowButton):
537
surface = WlSurface()
538
surface._ptr = self.wl_surface_ptr
539
child.window_id.set_rectangle(surface, *get_widget_rect(child))
540
541
child = child.get_next_sibling()
542
543
def on_button_click(self, button: WindowButton):
544
# Set a rectangle for animation
545
surface = WlSurface()
546
surface._ptr = self.wl_surface_ptr
547
button.window_id.set_rectangle(surface, *get_widget_rect(button))
548
if button.window_state.focused:
549
# Already pressed in, so minimise the focused window
550
button.window_id.set_minimized()
551
else:
552
button.window_id.unset_minimized()
553
button.window_id.activate(self.seat)
554
555
self.display.flush()
556
557
def on_state_changed(self, handle, states):
558
if handle in self.toplevel_buttons:
559
state_info = WindowState.from_state_array(states)
560
button = self.toplevel_buttons[handle]
561
button.window_state = state_info
562
if state_info.focused:
563
button.set_active(True)
564
else:
565
self.initial_button.set_active(True)
566
567
self.set_all_rectangles()
568
569
def on_app_id_changed(self, handle, app_id):
570
if handle in self.toplevel_buttons:
571
button = self.toplevel_buttons[handle]
572
button.set_icon_from_app_id(app_id)
573
app_ids = app_id.split()
574
for app_id in app_ids:
575
if app_id.startswith("wf-ipc-"):
576
self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button
577
578
def on_closed(self, handle):
579
button: WindowButton = self.toplevel_buttons[handle]
580
wf_id = button.wf_ipc_id
581
if handle in self.toplevel_buttons:
582
self.remove(self.toplevel_buttons[handle])
583
self.toplevel_buttons.pop(handle)
584
if wf_id in self.toplevel_buttons_by_wf_id:
585
self.toplevel_buttons_by_wf_id.pop(wf_id)
586
587
self.set_all_rectangles()
588
589
def make_context_menu(self):
590
menu = Gio.Menu()
591
menu.append(_("Window list _options"), "applet.options")
592
context_menu = Gtk.PopoverMenu.new_from_model(menu)
593
context_menu.set_has_arrow(False)
594
context_menu.set_parent(self)
595
context_menu.set_halign(Gtk.Align.START)
596
context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
597
return context_menu
598
599
def show_context_menu(self, gesture, n_presses, x, y):
600
rect = Gdk.Rectangle()
601
rect.x = int(x)
602
rect.y = int(y)
603
rect.width = 1
604
rect.height = 1
605
606
self.context_menu.set_pointing_to(rect)
607
self.context_menu.popup()
608
609
def show_options(self, _0=None, _1=None):
610
if self.options_window is None:
611
self.options_window = WindowListOptions()
612
self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
613
self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
614
615
def reset_window(*args):
616
self.options_window = None
617
618
self.options_window.connect("close-request", reset_window)
619
self.options_window.present()
620
621
def update_button_options(self, adjustment):
622
self.window_button_options.max_width = adjustment.get_value()
623
child: Gtk.Widget = self.get_first_child()
624
while child:
625
child.queue_allocate()
626
child.queue_resize()
627
child.queue_draw()
628
child = child.get_next_sibling()
629
630
self.emit("config-changed")
631
632
def get_config(self):
633
return {"max_button_width": self.window_button_options.max_width}
634
635
def output_changed(self):
636
self.get_wl_resources()
637
638
def make_draggable(self):
639
for button in self.toplevel_buttons.values():
640
button.remove_controller(button.drag_source)
641
button.set_sensitive(False)
642
643
def restore_drag(self):
644
for button in self.toplevel_buttons.values():
645
button.add_controller(button.drag_source)
646
button.set_sensitive(True)
647