"""
Traditional window list applet for the Panorama panel, compatible
with wlroots-based compositors.
Copyright 2025, roundabout-host.com <vlad@roundabout-host.com>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public Licence as published by
the Free Software Foundation, either version 3 of the Licence, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public Licence for more details.

You should have received a copy of the GNU General Public Licence
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""

import dataclasses
import os
import sys
import mmap
import locale
import typing
from pathlib import Path
from pywayland.utils import AnonymousFile
from pywayland.client import Display, EventQueue
from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlCompositor, WlOutput, WlBuffer, WlShmPool
from pywayland.protocol.wayland.wl_output import WlOutputProxy
from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import (
    ZwlrForeignToplevelManagerV1,
    ZwlrForeignToplevelHandleV1
)
from pywayland.protocol.wlr_screencopy_unstable_v1 import (
    ZwlrScreencopyManagerV1,
    ZwlrScreencopyFrameV1
)

import panorama_panel

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("GdkWayland", "4.0")

from gi.repository import Gtk, GLib, Gtk4LayerShell, Gio, Gdk, Pango, GObject


module_directory = Path(__file__).resolve().parent

locale.bindtextdomain("panorama-window-list", module_directory / "locale")
_ = lambda x: locale.dgettext("panorama-window-list", x)


import ctypes
from cffi import FFI
ffi = FFI()
ffi.cdef("""
void * gdk_wayland_display_get_wl_display (void * display);
void * gdk_wayland_surface_get_wl_surface (void * surface);
void * gdk_wayland_monitor_get_wl_output (void * monitor);
""")
gtk = ffi.dlopen("libgtk-4.so.1")


@Gtk.Template(filename=str(module_directory / "panorama-window-list-options.ui"))
class WindowListOptions(Gtk.Window):
    __gtype_name__ = "WindowListOptions"
    button_width_adjustment: Gtk.Adjustment = Gtk.Template.Child()
    workspace_filter_checkbutton: Gtk.CheckButton = Gtk.Template.Child()

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

        self.connect("close-request", lambda *args: self.destroy())


def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]:
    if len(array) % size:
        raise ValueError(f"The byte string's length must be a multiple of {size}")

    values: list[int] = []
    for i in range(0, len(array), size):
        values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder))

    return values


def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]:
    width = int(widget.get_width())
    height = int(widget.get_height())

    toplevel = widget.get_root()
    if not toplevel:
        return 0, 0, width, height

    x, y = widget.translate_coordinates(toplevel, 0, 0)
    x = int(x)
    y = int(y)

    return x, y, width, height


@dataclasses.dataclass
class WindowState:
    minimised: bool
    maximised: bool
    fullscreen: bool
    focused: bool

    @classmethod
    def from_state_array(cls, array: bytes):
        values = split_bytes_into_ints(array)
        instance = cls(False, False, False, False)
        for value in values:
            match value:
                case 0:
                    instance.maximised = True
                case 1:
                    instance.minimised = True
                case 2:
                    instance.focused = True
                case 3:
                    instance.fullscreen = True

        return instance


from gi.repository import Gtk, Gdk

class WindowButtonOptions:
    def __init__(self, max_width: int):
        self.max_width = max_width

class WindowButtonLayoutManager(Gtk.LayoutManager):
    def __init__(self, options: WindowButtonOptions, button: Gtk.ToggleButton, **kwargs):
        super().__init__(**kwargs)
        self.options = options
        self.button = button

    def do_measure(self, widget, orientation, for_size):
        child = widget.get_first_child()
        if child is None:
            return 0, 0, 0, 0

        if orientation == Gtk.Orientation.HORIZONTAL:
            min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size)
            width = self.options.max_width
            return min_width, width, min_height, nat_height
        else:
            min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size)
            return min_height, nat_height, 0, 0

    def do_allocate(self, widget, width, height, baseline):
        child = widget.get_first_child()
        if child is None:
            return
        alloc_width = min(width, self.options.max_width)
        alloc = Gdk.Rectangle()
        alloc.x = 0
        alloc.y = 0
        alloc.width = alloc_width
        alloc.height = height
        child.allocate(alloc.width, alloc.height, baseline)
        if alloc.width > 0 and alloc.height > 0 and self.button.window_list.wl_surface:
            x, y = widget.translate_coordinates(self.button.get_root(), 0, 0)
            x = int(x)
            y = int(y)
            self.button.window_id.set_rectangle(self.button.window_list.wl_surface, x, y, alloc.width, alloc.height)

class WindowButton(Gtk.ToggleButton):
    def __init__(self, window_id, window_title, window_list, **kwargs):
        super().__init__(**kwargs)

        self.window_id: ZwlrForeignToplevelHandleV1 = window_id
        self.wf_sock = window_list.wf_socket
        self.window_list = window_list
        self.wf_ipc_id: typing.Optional[int] = None
        self.popover_open = False
        self.set_has_frame(False)
        self.label = Gtk.Label()
        self.icon = Gtk.Image.new_from_icon_name("application-x-executable")
        box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
        box.append(self.icon)
        box.append(self.label)
        self.set_child(box)

        self.window_title = window_title
        self.window_state = WindowState(False, False, False, False)

        self.label.set_ellipsize(Pango.EllipsizeMode.END)
        self.set_hexpand(True)
        self.set_vexpand(True)

        self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE)
        self.drag_source.connect("prepare", self.provide_drag_data)
        self.drag_source.connect("drag-begin", self.drag_begin)
        self.drag_source.connect("drag-cancel", self.drag_cancel)

        self.add_controller(self.drag_source)

        self.menu = Gio.Menu()
        # TODO: toggle the labels when needed
        self.minimise_item = Gio.MenuItem.new(_("_Minimise"), "button.minimise")
        self.maximise_item = Gio.MenuItem.new(_("Ma_ximise"), "button.maximise")
        self.menu.append_item(self.minimise_item)
        self.menu.append_item(self.maximise_item)
        self.menu.append(_("_Close"), "button.close")
        self.menu.append(_("Window list _options"), "applet.options")
        self.popover_menu = Gtk.PopoverMenu.new_from_model(self.menu)
        self.popover_menu.set_parent(self)
        self.popover_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
        self.popover_menu.set_has_arrow(False)
        self.popover_menu.set_halign(Gtk.Align.END)
        self.popover_menu.connect("closed", self.hide_menu)

        self.right_click_controller = Gtk.GestureClick(button=3)
        self.right_click_controller.connect("pressed", self.show_menu)
        self.add_controller(self.right_click_controller)

        self.action_group = Gio.SimpleActionGroup()
        close_action = Gio.SimpleAction.new("close")
        close_action.connect("activate", self.close_associated)
        self.action_group.insert(close_action)
        minimise_action = Gio.SimpleAction.new("minimise")
        minimise_action.connect("activate", self.minimise_associated)
        self.action_group.insert(minimise_action)
        maximise_action = Gio.SimpleAction.new("maximise")
        maximise_action.connect("activate", self.maximise_associated)
        self.action_group.insert(maximise_action)

        self.insert_action_group("button", self.action_group)

        self.middle_click_controller = Gtk.GestureClick(button=2)
        self.middle_click_controller.connect("released", self.close_associated)
        self.add_controller(self.middle_click_controller)

        if not self.window_list.live_preview_tooltips:
            return

        self.custom_tooltip_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.custom_tooltip_content.append(TooltipMedia(window_list))
        self.hover_point = 0, 0
        self.hover_timeout = None
        self.connect("query-tooltip", self.query_tooltip)
        self.set_has_tooltip(True)
        motion_controller = Gtk.EventControllerMotion()
        motion_controller.connect("enter", self.on_button_enter)
        motion_controller.connect("leave", self.on_button_leave)
        self.add_controller(motion_controller)

    def query_tooltip(self, widget, x, y, keyboard_mode, tooltip):
        if self.window_list.get_live_preview_output() is None:
            return False
        tooltip.set_custom(self.custom_tooltip_content)
        return True

    def on_button_enter(self, button, x, y):
        if self.popover_open:
            return
        view_id = self.wf_ipc_id
        message = self.window_list.get_msg_template("live_previews/request_stream")
        message["data"]["id"] = view_id
        self.wf_sock.send_json(message)

    def on_button_leave(self, button):
        if self.popover_open:
            return
        message = self.window_list.get_msg_template("live_previews/release_output")
        self.wf_sock.send_json(message)
        self.window_list.set_live_preview_output(None)

    def show_menu(self, gesture, n_presses, x, y):
        if self.window_list.live_preview_tooltips:
            self.on_button_leave(None)
        self.popover_open = True
        self.popover_menu.popup()

    def hide_menu(self, popover):
        self.popover_open = False
        self.popover_menu.popdown()

    def close_associated(self, *args):
        self.window_id.close()

    def minimise_associated(self, action, *args):
        if self.window_state.minimised:
            self.window_id.unset_minimized()
        else:
            self.window_id.set_minimized()

    def maximise_associated(self, action, *args):
        if self.window_state.maximised:
            self.window_id.unset_maximized()
        else:
            self.window_id.set_maximized()

    def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float):
        app = self.get_root().get_application()
        app.drags[id(self)] = self
        value = GObject.Value()
        value.init(GObject.TYPE_UINT64)
        value.set_uint64(id(self))
        return Gdk.ContentProvider.new_for_value(value)

    def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag):
        paintable = Gtk.WidgetPaintable.new(self).get_current_image()
        source.set_icon(paintable, 0, 0)
        self.hide()

    def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason):
        self.show()
        return False

    @property
    def window_title(self):
        return self.label.get_text()

    @window_title.setter
    def window_title(self, value):
        self.label.set_text(value)

    def set_icon_from_app_id(self, app_id):
        app_ids = app_id.split()

        # If on Wayfire, find the IPC ID
        for app_id in app_ids:
            if app_id.startswith("wf-ipc-"):
                self.wf_ipc_id = int(app_id.removeprefix("wf-ipc-"))
                break

        # Try getting an icon from the correct theme
        icon_theme = Gtk.IconTheme.get_for_display(self.get_display())

        for app_id in app_ids:
            if icon_theme.has_icon(app_id):
                self.icon.set_from_icon_name(app_id)
                return

        # If that doesn't work, try getting one from .desktop files
        for app_id in app_ids:
            try:
                desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop")
                if desktop_file:
                    self.icon.set_from_gicon(desktop_file.get_icon())
                    return
            except TypeError:
                # Due to a bug, the constructor may sometimes return C NULL
                pass

class TooltipMedia(Gtk.Picture):
    def __init__(self, window_list):
        super().__init__()
        self.frame: ZwlrScreencopyFrameV1 = None
        self.screencopy_manager = window_list.screencopy_manager
        self.wl_shm = window_list.wl_shm
        self.window_list = window_list

        self.buffer_width = 200
        self.buffer_height = 200
        self.buffer_stride = 200 * 4

        self.screencopy_data = None
        self.wl_buffer = None
        self.size = 0

        self.add_tick_callback(self.on_tick)

    def frame_handle_buffer(self, frame, pixel_format, width, height, stride):
        size = width * height * int(stride / width)
        if self.size != size:
            self.set_size_request(width, height)
            self.size = size
            anon_file = AnonymousFile(self.size)
            anon_file.open()
            fd = anon_file.fd
            self.screencopy_data = mmap.mmap(fileno=fd, length=self.size, access=(mmap.ACCESS_READ | mmap.ACCESS_WRITE), offset=0)
            pool = self.wl_shm.create_pool(fd, self.size)
            self.wl_buffer = pool.create_buffer(0, width, height, stride, pixel_format)
            pool.destroy()
            anon_file.close()
            self.buffer_width = width
            self.buffer_height = height
            self.buffer_stride = stride
        self.frame.copy(self.wl_buffer)

    def frame_ready(self, frame, tv_sec_hi, tv_sec_low, tv_nsec):
        self.screencopy_data.seek(0)
        texture = Gdk.MemoryTexture.new(
            width=self.buffer_width,
            height=self.buffer_height,
            format=Gdk.MemoryFormat.R8G8B8A8,
            bytes=GLib.Bytes(self.screencopy_data.read(self.size)),
            stride=self.buffer_stride
        )
        self.set_paintable(texture)

    def request_next_frame(self):
        if self.frame:
            self.frame.destroy()

        wl_output = self.window_list.get_live_preview_output()

        if not wl_output:
            return

        self.frame = self.screencopy_manager.capture_output(1, wl_output)
        self.frame.dispatcher["buffer"] = self.frame_handle_buffer
        self.frame.dispatcher["ready"] = self.frame_ready

    def on_tick(self, widget, clock):
        self.request_next_frame()
        return GLib.SOURCE_CONTINUE

class WFWindowList(panorama_panel.Applet):
    name = _("Wayfire window list")
    description = _("Traditional window list (for Wayfire and other wlroots compositors)")

    def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
        super().__init__(orientation=orientation, config=config)
        if config is None:
            config = {}

        self.set_homogeneous(True)
        self.window_button_options = WindowButtonOptions(config.get("max_button_width", 256))
        self.show_only_this_wf_workspace = config.get("show_only_this_wf_workspace", True)
        self.show_only_this_output = config.get("show_only_this_output", True)
        self.icon_size = config.get("icon_size", 24)
        
        self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {}
        self.toplevel_buttons_by_wf_id: dict[int, WindowButton] = {}
        # This button doesn't belong to any window but is used for the button group and to be
        # selected when no window is focused
        self.initial_button = Gtk.ToggleButton()

        self.my_output = None
        self.wl_surface = None
        self.compositor = None
        self.wl_seat = None
        self.output = None
        self.wl_shm = None
        #self.manager = None
        self.screencopy_manager = None
        self.live_preview_output_name = None

        self.context_menu = self.make_context_menu()
        panorama_panel.track_popover(self.context_menu)

        right_click_controller = Gtk.GestureClick()
        right_click_controller.set_button(3)
        right_click_controller.connect("pressed", self.show_context_menu)

        self.add_controller(right_click_controller)

        action_group = Gio.SimpleActionGroup()
        options_action = Gio.SimpleAction.new("options", None)
        options_action.connect("activate", self.show_options)
        action_group.add_action(options_action)
        self.insert_action_group("applet", action_group)
        # Wait for the widget to be in a layer-shell window before doing this
        self.connect("realize", self.get_wl_resources)

        self.options_window = None

        # Support button reordering
        self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
        self.drop_target.set_gtypes([GObject.TYPE_UINT64])
        self.drop_target.connect("drop", self.drop_button)

        self.add_controller(self.drop_target)

        # Make a Wayfire socket for workspace handling
        self.live_preview_tooltips = False
        try:
            import wayfire
            from wayfire.core.template import get_msg_template
            self.wf_socket = wayfire.WayfireSocket()
            self.get_msg_template = get_msg_template
            if "live_previews/request_stream" in self.wf_socket.list_methods():
                self.live_preview_tooltips = True
                print("WFWindowList: Live Preview Tooltips: enabled")
            else:
                print("WFWindowList: Live Preview Tooltips: disabled")
                print("- Live preview tooltips requires wayfire wf-live-previews plugin. Is it enabled?")
            self.wf_socket.watch(["view-workspace-changed", "wset-workspace-changed"])
            fd = self.wf_socket.client.fileno()
            GLib.io_add_watch(GLib.IOChannel.unix_new(fd), GLib.IO_IN, self.on_wf_event, priority=GLib.PRIORITY_HIGH)
        except:
            # Wayfire raises Exception itself, so it cannot be narrowed down
            self.wf_socket = None

    def get_wf_output_by_name(self, name):
        if not self.wf_socket:
            return None
        for output in self.wf_socket.list_outputs():
            if output["name"] == name:
                return output
        return None

    def on_wf_event(self, source, condition):
        if condition & GLib.IO_IN:
            try:
                message = self.wf_socket.read_next_event()
                event = message.get("event")
                match event:
                    case "view-workspace-changed":
                        view = message.get("view", {})
                        if view["output-name"] == self.get_root().monitor_name:
                            output = self.get_wf_output_by_name(view["output-name"])
                            current_workspace = output["workspace"]["x"], output["workspace"]["y"]
                            button = self.toplevel_buttons_by_wf_id[view["id"]]
                            if not self.show_only_this_wf_workspace or (message["to"]["x"], message["to"]["y"]) == current_workspace:
                                if button.get_parent() is None and button.output == self.my_output:
                                    self.append(button)
                            else:
                                if button.get_parent() is self:
                                    # Remove out-of-workspace window
                                    self.remove(button)
                    case "wset-workspace-changed":
                        output_name = self.get_root().monitor_name
                        if message["wset-data"]["output-name"] == output_name:
                            # It has changed on this monitor; refresh the window list
                            self.filter_to_wf_workspace()

            except Exception as e:
                print("Error reading Wayfire event:", e)
        return True

    def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
        button: WindowButton = self.get_root().get_application().drags.pop(value)
        if button.get_parent() is not self:
            # Prevent dropping a button from another window list
            return False

        self.remove(button)
        # Find the position where to insert the applet
        # Probably we could use the assumption that buttons are homogeneous here for efficiency
        child = self.get_first_child()
        while child:
            allocation = child.get_allocation()
            child_x, child_y = self.translate_coordinates(self, 0, 0)
            if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
                midpoint = child_x + allocation.width / 2
                if x < midpoint:
                    button.insert_before(self, child)
                    break
            elif self.get_orientation() == Gtk.Orientation.VERTICAL:
                midpoint = child_y + allocation.height / 2
                if y < midpoint:
                    button.insert_before(self, child)
                    break
            child = child.get_next_sibling()
        else:
            self.append(button)
        button.show()

        self.set_all_rectangles()
        return True

    def filter_to_wf_workspace(self):
        if not self.show_only_this_wf_workspace:
            return

        output = self.get_wf_output_by_name(self.get_root().monitor_name)
        if not output:
            return
        for wf_id, button in self.toplevel_buttons_by_wf_id.items():
            view = self.wf_socket.get_view(wf_id)
            mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
            mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
            output_width = output["geometry"]["width"]
            output_height = output["geometry"]["height"]
            if 0 <= mid_x < output_width and 0 <= mid_y < output_height and button.output == self.my_output:
                # It is in this workspace; keep it
                if button.get_parent() is None:
                    self.append(button)
            else:
                # Remove it from this window list
                if button.get_parent() is self:
                    self.remove(button)

    def get_wl_resources(self, widget):
        if self.wf_socket is not None:
            self.filter_to_wf_workspace()

        if self.wl_shm:
            return

        self.my_output = None
        self.wl_seat = self.get_root().get_application().wl_seat
        self.output = None
        self.wl_shm = self.get_root().get_application().wl_shm
        self.screencopy_manager = self.get_root().get_application().screencopy_manager
        self.live_preview_output_name = None

    def set_live_preview_output(self, output):
        self.output = output

    def get_live_preview_output(self):
        return self.output

    def wl_output_enter(self, output, name):
        self.set_live_preview_output(None)
        if name.startswith("live-preview"):
            self.set_live_preview_output(output)
            return
        if name.startswith("live-preview"):
            return
        if not self.my_output and name == self.get_root().monitor_name:
            self.wl_surface = None
            if self.get_root().get_native().get_surface():
                wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
                        ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
                            self.get_root().get_native().get_surface().__gpointer__, None)))
                self.wl_surface = WlSurface()
                self.wl_surface._ptr = wl_surface_ptr
            self.my_output = output
        else:
            toplevel_buttons = self.toplevel_buttons.copy()
            for handle in toplevel_buttons:
                button = self.toplevel_buttons[handle]
                if output != button.output:
                    self.foreign_toplevel_closed(handle)

    def foreign_toplevel_output_enter(self, handle, output):
        if self.show_only_this_output and (not hasattr(output, "name") or output.name != self.get_root().monitor_name):
            return

        button = WindowButton(handle, handle.title, self)
        button.icon.set_pixel_size(self.icon_size)
        button.output = output
        self.set_title(button, handle.title)
        button.set_group(self.initial_button)
        button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options, button))
        button.connect("clicked", self.on_button_click)
        self.set_app_id(button, handle.app_id)
        self.toplevel_buttons[handle] = button
        if handle.app_id.rsplit(maxsplit=1)[-1].startswith("wf-ipc-"):
            wf_output = self.get_wf_output_by_name(self.get_root().monitor_name)
            wf_id = int(handle.app_id.rsplit(maxsplit=1)[-1].removeprefix("wf-ipc-"))
            view = self.wf_socket.get_view(wf_id)
            mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
            mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
            output_width = wf_output["geometry"]["width"]
            output_height = wf_output["geometry"]["height"]
            if 0 <= mid_x < output_width and 0 <= mid_y < output_height and button.output == self.my_output:
                # It is in this workspace; keep it
                if button.get_parent() is None:
                    self.append(button)
        else:
            self.append(button)

    def foreign_toplevel_output_leave(self, handle, output):
        if handle in self.toplevel_buttons:
            button = self.toplevel_buttons[handle]
            if button.get_parent() == self:
                button.output = None
                self.remove(button)
            self.set_all_rectangles()

    def set_title(self, button, title):
        button.window_title = title
        if not self.live_preview_tooltips:
            button.set_tooltip_text(title)

    def foreign_toplevel_title(self, handle, title):
        handle.title = title
        if handle in self.toplevel_buttons:
            button = self.toplevel_buttons[handle]
            self.set_title(button, title)

    def set_all_rectangles(self):
        child = self.get_first_child()
        while child is not None and self.wl_surface:
            if isinstance(child, WindowButton):
                x, y, w, h = get_widget_rect(child)
                if w > 0 and h > 0:
                    child.window_id.set_rectangle(self.wl_surface, x, y, w, h)

            child = child.get_next_sibling()

    def on_button_click(self, button: WindowButton):
        # Set a rectangle for animation
        button.window_id.set_rectangle(self.wl_surface, *get_widget_rect(button))
        if button.window_state.minimised:
            button.window_id.activate(self.wl_seat)
        else:
            if button.window_state.focused:
                button.window_id.set_minimized()
            else:
                button.window_id.activate(self.wl_seat)

    def foreign_toplevel_state(self, handle, states):
        if handle in self.toplevel_buttons:
            state_info = WindowState.from_state_array(states)
            button = self.toplevel_buttons[handle]
            button.window_state = state_info
            if state_info.focused:
                button.set_active(True)
            else:
                self.initial_button.set_active(True)

    def foreign_toplevel_refresh(self):
        for button in self.toplevel_buttons.values():
            self.remove(button)

        self.toplevel_buttons.clear()
        self.toplevel_buttons_by_wf_id.clear()

    def set_app_id(self, button, app_id):
        button.app_id = app_id
        button.set_icon_from_app_id(app_id)
        app_ids = app_id.split()
        for app_id in app_ids:
            if app_id.startswith("wf-ipc-"):
                self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button

    def foreign_toplevel_app_id(self, handle, app_id):
        handle.app_id = app_id
        if handle in self.toplevel_buttons:
            button = self.toplevel_buttons[handle]
            self.set_app_id(button, app_id)

    def foreign_toplevel_closed(self, handle):
        if handle not in self.toplevel_buttons:
            return
        button: WindowButton = self.toplevel_buttons[handle]
        wf_id = button.wf_ipc_id
        if handle in self.toplevel_buttons:
            self.remove(self.toplevel_buttons[handle])
            self.toplevel_buttons.pop(handle)
        if wf_id in self.toplevel_buttons_by_wf_id:
            self.toplevel_buttons_by_wf_id.pop(wf_id)

        self.set_all_rectangles()

    def make_context_menu(self):
        menu = Gio.Menu()
        menu.append(_("Window list _options"), "applet.options")
        context_menu = Gtk.PopoverMenu.new_from_model(menu)
        context_menu.set_has_arrow(False)
        context_menu.set_parent(self)
        context_menu.set_halign(Gtk.Align.START)
        context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
        return context_menu

    def show_context_menu(self, gesture, n_presses, x, y):
        rect = Gdk.Rectangle()
        rect.x = int(x)
        rect.y = int(y)
        rect.width = 1
        rect.height = 1

        self.context_menu.set_pointing_to(rect)
        self.context_menu.popup()

    def show_options(self, _0=None, _1=None):
        if self.options_window is None:
            self.options_window = WindowListOptions()
            self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
            self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
            self.options_window.workspace_filter_checkbutton.set_active(self.show_only_this_wf_workspace)
            self.options_window.workspace_filter_checkbutton.connect("toggled", self.update_workspace_filter)

            def reset_window(*args):
                self.options_window = None

            self.options_window.connect("close-request", reset_window)
        self.options_window.present()

    def remove_workspace_filtering(self):
        for button in self.toplevel_buttons.values():
            if button.get_parent() is None:
                self.append(button)

    def update_workspace_filter(self, checkbutton):
        self.show_only_this_wf_workspace = checkbutton.get_active()
        if checkbutton.get_active():
            self.filter_to_wf_workspace()
        else:
            self.remove_workspace_filtering()

    def update_button_options(self, adjustment):
        self.window_button_options.max_width = adjustment.get_value()
        child: Gtk.Widget = self.get_first_child()
        while child:
            child.queue_allocate()
            child.queue_resize()
            child.queue_draw()
            child = child.get_next_sibling()

        self.emit("config-changed")

    def get_config(self):
        return {
            "max_button_width": self.window_button_options.max_width,
            "show_only_this_wf_workspace": self.show_only_this_wf_workspace,
            "show_only_this_output": self.show_only_this_output,
        }

    def make_draggable(self):
        for button in self.toplevel_buttons.values():
            button.remove_controller(button.drag_source)
            button.set_sensitive(False)

    def restore_drag(self):
        for button in self.toplevel_buttons.values():
            button.add_controller(button.drag_source)
            button.set_sensitive(True)
