__init__.py
Python script, ASCII text executable
1""" 2Traditional window list applet for the Panorama panel, compatible 3with wlroots-based compositors. 4Copyright 2025, roundabout-host.com <vlad@roundabout-host.com> 5 6This program is free software: you can redistribute it and/or modify 7it under the terms of the GNU General Public Licence as published by 8the Free Software Foundation, either version 3 of the Licence, or 9(at your option) any later version. 10 11This program is distributed in the hope that it will be useful, 12but WITHOUT ANY WARRANTY; without even the implied warranty of 13MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14GNU General Public Licence for more details. 15 16You should have received a copy of the GNU General Public Licence 17along with this program. If not, see <https://www.gnu.org/licenses/>. 18""" 19 20import dataclasses 21import os 22import sys 23import mmap 24import locale 25import typing 26from pathlib import Path 27from pywayland.utils import AnonymousFile 28from pywayland.client import Display, EventQueue 29from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlCompositor, WlOutput, WlBuffer, WlShm, WlShmPool 30from pywayland.protocol.wayland.wl_output import WlOutputProxy 31from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import ( 32ZwlrForeignToplevelManagerV1, 33ZwlrForeignToplevelHandleV1 34) 35from pywayland.protocol.wlr_screencopy_unstable_v1 import ( 36ZwlrScreencopyManagerV1, 37ZwlrScreencopyFrameV1 38) 39from OpenGL import GL 40import panorama_panel 41 42import gi 43 44gi.require_version("Gtk", "4.0") 45gi.require_version("GdkWayland", "4.0") 46 47from gi.repository import Gtk, GLib, Gtk4LayerShell, Gio, Gdk, Pango, GObject 48 49 50module_directory = Path(__file__).resolve().parent 51 52locale.bindtextdomain("panorama-window-list", module_directory / "locale") 53_ = lambda x: locale.dgettext("panorama-window-list", x) 54 55 56import ctypes 57from cffi import FFI 58ffi = FFI() 59ffi.cdef(""" 60void * gdk_wayland_display_get_wl_display (void * display); 61void * gdk_wayland_surface_get_wl_surface (void * surface); 62void * gdk_wayland_monitor_get_wl_output (void * monitor); 63""") 64gtk = ffi.dlopen("libgtk-4.so.1") 65 66 67@Gtk.Template(filename=str(module_directory / "panorama-window-list-options.ui")) 68class WindowListOptions(Gtk.Window): 69__gtype_name__ = "WindowListOptions" 70button_width_adjustment: Gtk.Adjustment = Gtk.Template.Child() 71workspace_filter_checkbutton: Gtk.CheckButton = Gtk.Template.Child() 72 73def __init__(self, **kwargs): 74super().__init__(**kwargs) 75 76self.connect("close-request", lambda *args: self.destroy()) 77 78 79def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]: 80if len(array) % size: 81raise ValueError(f"The byte string's length must be a multiple of {size}") 82 83values: list[int] = [] 84for i in range(0, len(array), size): 85values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder)) 86 87return values 88 89 90def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]: 91width = int(widget.get_width()) 92height = int(widget.get_height()) 93 94toplevel = widget.get_root() 95if not toplevel: 96return None, None, width, height 97 98x, y = widget.translate_coordinates(toplevel, 0, 0) 99x = int(x) 100y = int(y) 101 102return x, y, width, height 103 104 105@dataclasses.dataclass 106class WindowState: 107minimised: bool 108maximised: bool 109fullscreen: bool 110focused: bool 111 112@classmethod 113def from_state_array(cls, array: bytes): 114values = split_bytes_into_ints(array) 115instance = cls(False, False, False, False) 116for value in values: 117match value: 118case 0: 119instance.maximised = True 120case 1: 121instance.minimised = True 122case 2: 123instance.focused = True 124case 3: 125instance.fullscreen = True 126 127return instance 128 129 130from gi.repository import Gtk, Gdk 131 132class WindowButtonOptions: 133def __init__(self, max_width: int): 134self.max_width = max_width 135 136class WindowButtonLayoutManager(Gtk.LayoutManager): 137def __init__(self, options: WindowButtonOptions, **kwargs): 138super().__init__(**kwargs) 139self.options = options 140 141def do_measure(self, widget, orientation, for_size): 142child = widget.get_first_child() 143if child is None: 144return 0, 0, 0, 0 145 146if orientation == Gtk.Orientation.HORIZONTAL: 147min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size) 148width = self.options.max_width 149return min_width, width, min_height, nat_height 150else: 151min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size) 152return min_height, nat_height, 0, 0 153 154def do_allocate(self, widget, width, height, baseline): 155child = widget.get_first_child() 156if child is None: 157return 158alloc_width = min(width, self.options.max_width) 159alloc = Gdk.Rectangle() 160alloc.x = 0 161alloc.y = 0 162alloc.width = alloc_width 163alloc.height = height 164child.allocate(alloc.width, alloc.height, baseline) 165 166 167class WindowButton(Gtk.ToggleButton): 168def __init__(self, window_id, window_title, window_list, **kwargs): 169super().__init__(**kwargs) 170 171self.window_id: ZwlrForeignToplevelHandleV1 = window_id 172self.wf_sock = window_list.wf_socket 173self.window_list = window_list 174self.wf_ipc_id: typing.Optional[int] = None 175self.set_has_frame(False) 176self.label = Gtk.Label() 177self.icon = Gtk.Image.new_from_icon_name("application-x-executable") 178box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) 179box.append(self.icon) 180box.append(self.label) 181self.set_child(box) 182 183self.window_title = window_title 184self.window_state = WindowState(False, False, False, False) 185 186self.label.set_ellipsize(Pango.EllipsizeMode.END) 187self.set_hexpand(True) 188self.set_vexpand(True) 189 190self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE) 191self.drag_source.connect("prepare", self.provide_drag_data) 192self.drag_source.connect("drag-begin", self.drag_begin) 193self.drag_source.connect("drag-cancel", self.drag_cancel) 194 195self.add_controller(self.drag_source) 196 197self.menu = Gio.Menu() 198# TODO: toggle the labels when needed 199self.minimise_item = Gio.MenuItem.new(_("_Minimise"), "button.minimise") 200self.maximise_item = Gio.MenuItem.new(_("Ma_ximise"), "button.maximise") 201self.menu.append_item(self.minimise_item) 202self.menu.append_item(self.maximise_item) 203self.menu.append(_("_Close"), "button.close") 204self.menu.append(_("Window list _options"), "applet.options") 205self.popover_menu = Gtk.PopoverMenu.new_from_model(self.menu) 206self.popover_menu.set_parent(self) 207self.popover_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 208self.popover_menu.set_has_arrow(False) 209self.popover_menu.set_halign(Gtk.Align.END) 210 211self.right_click_controller = Gtk.GestureClick(button=3) 212self.right_click_controller.connect("pressed", self.show_menu) 213self.add_controller(self.right_click_controller) 214 215self.action_group = Gio.SimpleActionGroup() 216close_action = Gio.SimpleAction.new("close") 217close_action.connect("activate", self.close_associated) 218self.action_group.insert(close_action) 219minimise_action = Gio.SimpleAction.new("minimise") 220minimise_action.connect("activate", self.minimise_associated) 221self.action_group.insert(minimise_action) 222maximise_action = Gio.SimpleAction.new("maximise") 223maximise_action.connect("activate", self.maximise_associated) 224self.action_group.insert(maximise_action) 225 226self.insert_action_group("button", self.action_group) 227 228self.middle_click_controller = Gtk.GestureClick(button=2) 229self.middle_click_controller.connect("released", self.close_associated) 230self.add_controller(self.middle_click_controller) 231 232if not self.window_list.live_preview_tooltips: 233return 234 235self.custom_tooltip_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 236self.custom_tooltip_content.append(TooltipMedia(window_list)) 237self.hover_point = 0, 0 238self.hover_timeout = None 239self.connect("query-tooltip", self.query_tooltip) 240self.set_has_tooltip(True) 241motion_controller = Gtk.EventControllerMotion() 242motion_controller.connect("enter", self.on_button_enter) 243motion_controller.connect("leave", self.on_button_leave) 244self.add_controller(motion_controller) 245 246def query_tooltip(self, widget, x, y, keyboard_mode, tooltip): 247tooltip.set_custom(self.custom_tooltip_content) 248return True 249 250def on_button_enter(self, button, x, y): 251view_id = self.wf_ipc_id 252self.window_list.set_live_preview_output_name("live-preview-" + str(view_id)) 253message = self.window_list.get_msg_template("live_previews/request_stream") 254message["data"]["id"] = view_id 255self.wf_sock.send_json(message) 256 257def on_button_leave(self, button): 258message = self.window_list.get_msg_template("live_previews/release_output") 259self.wf_sock.send_json(message) 260 261def show_menu(self, gesture, n_presses, x, y): 262rect = Gdk.Rectangle() 263rect.x = int(x) 264rect.y = int(y) 265rect.width = 1 266rect.height = 1 267self.popover_menu.popup() 268 269def close_associated(self, *args): 270self.window_id.close() 271 272def minimise_associated(self, action, *args): 273if self.window_state.minimised: 274self.window_id.unset_minimized() 275else: 276self.window_id.set_minimized() 277 278def maximise_associated(self, action, *args): 279if self.window_state.maximised: 280self.window_id.unset_maximized() 281else: 282self.window_id.set_maximized() 283 284def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float): 285app = self.get_root().get_application() 286app.drags[id(self)] = self 287value = GObject.Value() 288value.init(GObject.TYPE_UINT64) 289value.set_uint64(id(self)) 290return Gdk.ContentProvider.new_for_value(value) 291 292def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag): 293paintable = Gtk.WidgetPaintable.new(self).get_current_image() 294source.set_icon(paintable, 0, 0) 295self.hide() 296 297def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason): 298self.show() 299return False 300 301@property 302def window_title(self): 303return self.label.get_text() 304 305@window_title.setter 306def window_title(self, value): 307self.label.set_text(value) 308 309def set_icon_from_app_id(self, app_id): 310app_ids = app_id.split() 311 312# If on Wayfire, find the IPC ID 313for app_id in app_ids: 314if app_id.startswith("wf-ipc-"): 315self.wf_ipc_id = int(app_id.removeprefix("wf-ipc-")) 316break 317 318# Try getting an icon from the correct theme 319icon_theme = Gtk.IconTheme.get_for_display(self.get_display()) 320 321for app_id in app_ids: 322if icon_theme.has_icon(app_id): 323self.icon.set_from_icon_name(app_id) 324return 325 326# If that doesn't work, try getting one from .desktop files 327for app_id in app_ids: 328try: 329desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop") 330if desktop_file: 331self.icon.set_from_gicon(desktop_file.get_icon()) 332return 333except TypeError: 334# Due to a bug, the constructor may sometimes return C NULL 335pass 336 337class TooltipMedia(Gtk.Picture): 338def __init__(self, window_list): 339super().__init__() 340self.frame: ZwlrScreencopyFrameV1 = None 341self.screencopy_manager = window_list.screencopy_manager 342self.wl_shm = window_list.wl_shm 343self.window_list = window_list 344 345self.buffer_width = 200 346self.buffer_height = 200 347self.buffer_stride = 200 * 4 348 349self.screencopy_data = None 350self.wl_buffer = None 351self.size = 0 352 353self.add_tick_callback(self.on_tick) 354self.request_next_frame() 355 356 357def frame_handle_buffer(self, frame, pixel_format, width, height, stride): 358size = width * height * int(stride / width) 359if self.size != size: 360self.set_size_request(width, height) 361self.size = size 362anon_file = AnonymousFile(self.size) 363anon_file.open() 364fd = anon_file.fd 365self.screencopy_data = mmap.mmap(fileno=fd, length=self.size, access=(mmap.ACCESS_READ | mmap.ACCESS_WRITE), offset=0) 366pool = self.wl_shm.create_pool(fd, self.size) 367self.wl_buffer = pool.create_buffer(0, width, height, stride, pixel_format) 368pool.destroy() 369anon_file.close() 370self.buffer_width = width 371self.buffer_height = height 372self.buffer_stride = stride 373self.frame.copy(self.wl_buffer) 374 375def frame_ready(self, frame, tv_sec_hi, tv_sec_low, tv_nsec): 376self.screencopy_data.seek(0) 377texture = Gdk.MemoryTexture.new( 378width=self.buffer_width, 379height=self.buffer_height, 380format=Gdk.MemoryFormat.R8G8B8A8, 381bytes=GLib.Bytes(self.screencopy_data.read(self.size)), 382stride=self.buffer_stride 383) 384self.set_paintable(texture) 385 386def request_next_frame(self): 387if self.frame: 388self.frame.destroy() 389 390wl_output = self.window_list.get_live_preview_output() 391 392if not wl_output: 393return 394 395self.frame = self.screencopy_manager.capture_output(1, wl_output) 396self.frame.dispatcher["buffer"] = self.frame_handle_buffer 397self.frame.dispatcher["ready"] = self.frame_ready 398 399def on_tick(self, widget, clock): 400self.request_next_frame() 401return GLib.SOURCE_CONTINUE 402 403class WFWindowList(panorama_panel.Applet): 404name = _("Wayfire window list") 405description = _("Traditional window list (for Wayfire and other wlroots compositors)") 406 407def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None): 408super().__init__(orientation=orientation, config=config) 409if config is None: 410config = {} 411 412self.set_homogeneous(True) 413self.window_button_options = WindowButtonOptions(config.get("max_button_width", 256)) 414self.show_only_this_wf_workspace = config.get("show_only_this_wf_workspace", True) 415self.show_only_this_output = config.get("show_only_this_output", True) 416self.icon_size = config.get("icon_size", 24) 417 418self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {} 419self.toplevel_buttons_by_wf_id: dict[int, WindowButton] = {} 420# This button doesn't belong to any window but is used for the button group and to be 421# selected when no window is focused 422self.initial_button = Gtk.ToggleButton() 423 424self.display = None 425self.my_output = None 426self.wl_surface_ptr = None 427self.registry = None 428self.compositor = None 429self.seat = None 430 431self.context_menu = self.make_context_menu() 432panorama_panel.track_popover(self.context_menu) 433 434right_click_controller = Gtk.GestureClick() 435right_click_controller.set_button(3) 436right_click_controller.connect("pressed", self.show_context_menu) 437 438self.add_controller(right_click_controller) 439 440action_group = Gio.SimpleActionGroup() 441options_action = Gio.SimpleAction.new("options", None) 442options_action.connect("activate", self.show_options) 443action_group.add_action(options_action) 444self.insert_action_group("applet", action_group) 445# Wait for the widget to be in a layer-shell window before doing this 446self.connect("realize", self.get_wl_resources) 447 448self.options_window = None 449 450# Support button reordering 451self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE) 452self.drop_target.set_gtypes([GObject.TYPE_UINT64]) 453self.drop_target.connect("drop", self.drop_button) 454 455self.add_controller(self.drop_target) 456 457# Make a Wayfire socket for workspace handling 458self.live_preview_tooltips = False 459try: 460import wayfire 461from wayfire.core.template import get_msg_template 462self.wf_socket = wayfire.WayfireSocket() 463self.get_msg_template = get_msg_template 464if "live_previews/request_stream" in self.wf_socket.list_methods(): 465self.live_preview_tooltips = True 466print("WFWindowList: Live Preview Tooltips: enabled") 467else: 468print("WFWindowList: Live Preview Tooltips: disabled") 469print("- Live preview tooltips requires wayfire wf-live-previews plugin. Is it enabled?") 470self.wf_socket.watch(["view-workspace-changed", "wset-workspace-changed"]) 471fd = self.wf_socket.client.fileno() 472GLib.io_add_watch(GLib.IOChannel.unix_new(fd), GLib.IO_IN, self.on_wf_event, priority=GLib.PRIORITY_HIGH) 473except: 474# Wayfire raises Exception itself, so it cannot be narrowed down 475self.wf_socket = None 476 477def get_wf_output_by_name(self, name): 478if not self.wf_socket: 479return None 480for output in self.wf_socket.list_outputs(): 481if output["name"] == name: 482return output 483return None 484 485def on_wf_event(self, source, condition): 486if condition & GLib.IO_IN: 487try: 488message = self.wf_socket.read_next_event() 489event = message.get("event") 490match event: 491case "view-workspace-changed": 492view = message.get("view", {}) 493if view["output-name"] == self.get_root().monitor_name: 494output = self.get_wf_output_by_name(view["output-name"]) 495current_workspace = output["workspace"]["x"], output["workspace"]["y"] 496button = self.toplevel_buttons_by_wf_id[view["id"]] 497if not self.show_only_this_wf_workspace or (message["to"]["x"], message["to"]["y"]) == current_workspace: 498if button.get_parent() is None and button.output == self.my_output: 499self.append(button) 500else: 501if button.get_parent() is self: 502# Remove out-of-workspace window 503self.remove(button) 504case "wset-workspace-changed": 505output_name = self.get_root().monitor_name 506if message["wset-data"]["output-name"] == output_name: 507# It has changed on this monitor; refresh the window list 508self.filter_to_wf_workspace() 509 510except Exception as e: 511print("Error reading Wayfire event:", e) 512return True 513 514def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float): 515button: WindowButton = self.get_root().get_application().drags.pop(value) 516if button.get_parent() is not self: 517# Prevent dropping a button from another window list 518return False 519 520self.remove(button) 521# Find the position where to insert the applet 522# Probably we could use the assumption that buttons are homogeneous here for efficiency 523child = self.get_first_child() 524while child: 525allocation = child.get_allocation() 526child_x, child_y = self.translate_coordinates(self, 0, 0) 527if self.get_orientation() == Gtk.Orientation.HORIZONTAL: 528midpoint = child_x + allocation.width / 2 529if x < midpoint: 530button.insert_before(self, child) 531break 532elif self.get_orientation() == Gtk.Orientation.VERTICAL: 533midpoint = child_y + allocation.height / 2 534if y < midpoint: 535button.insert_before(self, child) 536break 537child = child.get_next_sibling() 538else: 539self.append(button) 540button.show() 541 542self.set_all_rectangles() 543return True 544 545def filter_to_wf_workspace(self): 546if not self.show_only_this_wf_workspace: 547return 548 549output = self.get_wf_output_by_name(self.get_root().monitor_name) 550for wf_id, button in self.toplevel_buttons_by_wf_id.items(): 551view = self.wf_socket.get_view(wf_id) 552mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2 553mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2 554output_width = output["geometry"]["width"] 555output_height = output["geometry"]["height"] 556if 0 <= mid_x < output_width and 0 <= mid_y < output_height and button.output == self.my_output: 557# It is in this workspace; keep it 558if button.get_parent() is None: 559self.append(button) 560else: 561# Remove it from this window list 562if button.get_parent() is self: 563self.remove(button) 564 565def get_wl_resources(self, widget): 566ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 567ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 568 569self.display = Display() 570wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 571ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None))) 572self.display._ptr = wl_display_ptr 573self.event_queue = EventQueue(self.display) 574 575# Intentionally commented: the display is already connected by GTK 576# self.display.connect() 577 578my_monitor = Gtk4LayerShell.get_monitor(self.get_root()) 579 580# Iterate through monitors and get their Wayland output (wl_output) 581# This is a hack to ensure output_enter/leave is called for toplevels 582for monitor in self.get_root().get_application().monitors: 583wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None))) 584if wl_output and not monitor.output_proxy: 585print("Create proxy") 586output_proxy = WlOutputProxy(wl_output, self.display) 587output_proxy.interface.registry[output_proxy._ptr] = output_proxy 588monitor.output_proxy = output_proxy 589 590if monitor == my_monitor: 591self.my_output = monitor.output_proxy 592# End hack 593 594self.output = None 595self.wl_shm = None 596self.manager = None 597self.screencopy_manager = None 598self.live_preview_output_name = None 599self.registry = self.display.get_registry() 600self.registry.dispatcher["global"] = self.on_global 601self.display.roundtrip() 602if self.manager is None: 603print("Could not load wf-window-list. Is foreign-toplevel protocol advertised by the compositor?") 604print("(Wayfire requires enabling foreign-toplevel plugin)") 605return 606if self.screencopy_manager is None: 607print("Protocol zwlr_screencopy_manager_v1 not found, live (minimized) window previews will not work!") 608self.manager.dispatcher["toplevel"] = self.on_new_toplevel 609self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished") 610self.display.roundtrip() 611fd = self.display.get_fd() 612GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event) 613 614if self.wf_socket is not None: 615self.filter_to_wf_workspace() 616 617def on_display_event(self, source, condition): 618if condition & GLib.IO_IN: 619self.display.dispatch(queue=self.event_queue) 620return True 621 622def set_live_preview_output(self, output): 623self.output = output 624 625def get_live_preview_output(self): 626return self.output 627 628def set_live_preview_output_name(self, name): 629self.live_preview_output_name = name 630 631def get_live_preview_output_name(self): 632return self.live_preview_output_name 633 634def wl_output_handle_name(self, output, name): 635if name != self.get_live_preview_output_name(): 636output.release() 637output.destroy() 638return 639self.set_live_preview_output(output) 640 641def on_global(self, registry, name, interface, version): 642if interface == "zwlr_foreign_toplevel_manager_v1": 643self.print_log("Interface registered") 644self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version) 645elif interface == "wl_output": 646if self.live_preview_output_name is None: 647return 648wl_output = registry.bind(name, WlOutput, version) 649wl_output.dispatcher["name"] = self.wl_output_handle_name 650elif interface == "wl_seat": 651self.print_log("Seat found") 652self.seat = registry.bind(name, WlSeat, version) 653elif interface == "wl_shm": 654self.wl_shm = registry.bind(name, WlShm, version) 655elif interface == "wl_compositor": 656self.compositor = registry.bind(name, WlCompositor, version) 657wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface( 658ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer( 659self.get_root().get_native().get_surface().__gpointer__, None))) 660self.wl_surface = WlSurface() 661self.wl_surface._ptr = wl_surface_ptr 662elif interface == "zwlr_screencopy_manager_v1": 663self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version) 664 665def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1, 666handle: ZwlrForeignToplevelHandleV1): 667handle.dispatcher["title"] = self.on_title_changed 668handle.dispatcher["app_id"] = self.on_app_id_changed 669handle.dispatcher["output_enter"] = self.on_output_entered 670handle.dispatcher["output_leave"] = self.on_output_left 671handle.dispatcher["state"] = self.on_state_changed 672handle.dispatcher["closed"] = self.on_closed 673self.display.roundtrip() 674 675def on_output_entered(self, handle, output): 676button = WindowButton(handle, handle.title, self) 677button.icon.set_pixel_size(self.icon_size) 678button.output = None 679 680if self.show_only_this_output and output != self.my_output: 681return 682 683button.output = output 684self.set_title(button, handle.title) 685button.set_group(self.initial_button) 686button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options)) 687button.connect("clicked", self.on_button_click) 688self.set_app_id(button, handle.app_id) 689self.toplevel_buttons[handle] = button 690self.append(button) 691self.set_all_rectangles() 692 693def on_output_left(self, handle, output): 694if handle in self.toplevel_buttons: 695button = self.toplevel_buttons[handle] 696if button.get_parent() == self: 697button.output = None 698self.remove(button) 699self.set_all_rectangles() 700 701def set_title(self, button, title): 702button.window_title = title 703if not self.live_preview_tooltips: 704button.set_tooltip_text(title) 705 706def on_title_changed(self, handle, title): 707handle.title = title 708if handle in self.toplevel_buttons: 709button = self.toplevel_buttons[handle] 710self.set_title(button, title) 711 712def set_all_rectangles(self): 713child = self.get_first_child() 714while child is not None: 715if isinstance(child, WindowButton): 716child.window_id.set_rectangle(self.wl_surface, *get_widget_rect(child)) 717 718child = child.get_next_sibling() 719 720def on_button_click(self, button: WindowButton): 721# Set a rectangle for animation 722button.window_id.set_rectangle(self.wl_surface, *get_widget_rect(button)) 723if button.window_state.minimised: 724button.window_id.activate(self.seat) 725else: 726if button.window_state.focused: 727button.window_id.set_minimized() 728else: 729button.window_id.activate(self.seat) 730 731def on_state_changed(self, handle, states): 732if handle in self.toplevel_buttons: 733state_info = WindowState.from_state_array(states) 734button = self.toplevel_buttons[handle] 735button.window_state = state_info 736if state_info.focused: 737button.set_active(True) 738else: 739self.initial_button.set_active(True) 740 741self.set_all_rectangles() 742 743def set_app_id(self, button, app_id): 744button.app_id = app_id 745button.set_icon_from_app_id(app_id) 746app_ids = app_id.split() 747for app_id in app_ids: 748if app_id.startswith("wf-ipc-"): 749self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button 750 751def on_app_id_changed(self, handle, app_id): 752handle.app_id = app_id 753if handle in self.toplevel_buttons: 754button = self.toplevel_buttons[handle] 755self.set_app_id(button, app_id) 756 757def on_closed(self, handle): 758if handle not in self.toplevel_buttons: 759return 760button: WindowButton = self.toplevel_buttons[handle] 761wf_id = button.wf_ipc_id 762if handle in self.toplevel_buttons: 763self.remove(self.toplevel_buttons[handle]) 764self.toplevel_buttons.pop(handle) 765if wf_id in self.toplevel_buttons_by_wf_id: 766self.toplevel_buttons_by_wf_id.pop(wf_id) 767 768self.set_all_rectangles() 769 770def make_context_menu(self): 771menu = Gio.Menu() 772menu.append(_("Window list _options"), "applet.options") 773context_menu = Gtk.PopoverMenu.new_from_model(menu) 774context_menu.set_has_arrow(False) 775context_menu.set_parent(self) 776context_menu.set_halign(Gtk.Align.START) 777context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 778return context_menu 779 780def show_context_menu(self, gesture, n_presses, x, y): 781rect = Gdk.Rectangle() 782rect.x = int(x) 783rect.y = int(y) 784rect.width = 1 785rect.height = 1 786 787self.context_menu.set_pointing_to(rect) 788self.context_menu.popup() 789 790def show_options(self, _0=None, _1=None): 791if self.options_window is None: 792self.options_window = WindowListOptions() 793self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width) 794self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options) 795self.options_window.workspace_filter_checkbutton.set_active(self.show_only_this_wf_workspace) 796self.options_window.workspace_filter_checkbutton.connect("toggled", self.update_workspace_filter) 797 798def reset_window(*args): 799self.options_window = None 800 801self.options_window.connect("close-request", reset_window) 802self.options_window.present() 803 804def remove_workspace_filtering(self): 805for button in self.toplevel_buttons.values(): 806if button.get_parent() is None: 807self.append(button) 808 809def update_workspace_filter(self, checkbutton): 810self.show_only_this_wf_workspace = checkbutton.get_active() 811if checkbutton.get_active(): 812self.filter_to_wf_workspace() 813else: 814self.remove_workspace_filtering() 815 816def update_button_options(self, adjustment): 817self.window_button_options.max_width = adjustment.get_value() 818child: Gtk.Widget = self.get_first_child() 819while child: 820child.queue_allocate() 821child.queue_resize() 822child.queue_draw() 823child = child.get_next_sibling() 824 825self.emit("config-changed") 826 827def get_config(self): 828return { 829"max_button_width": self.window_button_options.max_width, 830"show_only_this_wf_workspace": self.show_only_this_wf_workspace, 831"show_only_this_output": self.show_only_this_output, 832} 833 834def output_changed(self): 835self.get_wl_resources() 836 837def make_draggable(self): 838for button in self.toplevel_buttons.values(): 839button.remove_controller(button.drag_source) 840button.set_sensitive(False) 841 842def restore_drag(self): 843for button in self.toplevel_buttons.values(): 844button.add_controller(button.drag_source) 845button.set_sensitive(True) 846