"""
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, WlShm, 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
)
from OpenGL import GL
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 None, None, 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, **kwargs):
        super().__init__(**kwargs)
        self.options = options

    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)


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.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.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):
        tooltip.set_custom(self.custom_tooltip_content)
        return True

    def on_button_enter(self, button, x, y):
        view_id = self.wf_ipc_id
        self.window_list.set_live_preview_output_name("live-preview-" + str(view_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):
        message = self.window_list.get_msg_template("live_previews/release_output")
        self.wf_sock.send_json(message)

    def show_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.popover_menu.popup()

    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)
        self.request_next_frame()


    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.display = None
        self.my_output = None
        self.wl_surface_ptr = None
        self.registry = None
        self.compositor = None
        self.seat = 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)
        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):
        ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
        ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)

        self.display = Display()
        wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
                ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None)))
        self.display._ptr = wl_display_ptr
        self.event_queue = EventQueue(self.display)

        # Intentionally commented: the display is already connected by GTK
        # self.display.connect()

        my_monitor = Gtk4LayerShell.get_monitor(self.get_root())

        # Iterate through monitors and get their Wayland output (wl_output)
        # This is a hack to ensure output_enter/leave is called for toplevels
        for monitor in self.get_root().get_application().monitors:
            wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
            if wl_output and not monitor.output_proxy:
                print("Create proxy")
                output_proxy = WlOutputProxy(wl_output, self.display)
                output_proxy.interface.registry[output_proxy._ptr] = output_proxy
                monitor.output_proxy = output_proxy

            if monitor == my_monitor:
                self.my_output = monitor.output_proxy
        # End hack

        self.output = None
        self.wl_shm = None
        self.manager = None
        self.screencopy_manager = None
        self.live_preview_output_name = None
        self.registry = self.display.get_registry()
        self.registry.dispatcher["global"] = self.on_global
        self.display.roundtrip()
        if self.manager is None:
            print("Could not load wf-window-list. Is foreign-toplevel protocol advertised by the compositor?")
            print("(Wayfire requires enabling foreign-toplevel plugin)")
            return
        if self.screencopy_manager is None:
            print("Protocol zwlr_screencopy_manager_v1 not found, live (minimized) window previews will not work!")
        self.manager.dispatcher["toplevel"] = self.on_new_toplevel
        self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
        self.display.roundtrip()
        fd = self.display.get_fd()
        GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event)

        if self.wf_socket is not None:
            self.filter_to_wf_workspace()

    def on_display_event(self, source, condition):
        if condition & GLib.IO_IN:
            self.display.dispatch(queue=self.event_queue)
        return True

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

    def get_live_preview_output(self):
        return self.output

    def set_live_preview_output_name(self, name):
        self.live_preview_output_name = name

    def get_live_preview_output_name(self):
        return self.live_preview_output_name

    def wl_output_handle_name(self, output, name):
        if name != self.get_live_preview_output_name():
            output.release()
            output.destroy()
            return
        self.set_live_preview_output(output)

    def on_global(self, registry, name, interface, version):
        if interface == "zwlr_foreign_toplevel_manager_v1":
            self.print_log("Interface registered")
            self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version)
        elif interface == "wl_output":
            if self.live_preview_output_name is None:
                return
            wl_output = registry.bind(name, WlOutput, version)
            wl_output.dispatcher["name"] = self.wl_output_handle_name
        elif interface == "wl_seat":
            self.print_log("Seat found")
            self.seat = registry.bind(name, WlSeat, version)
        elif interface == "wl_shm":
            self.wl_shm = registry.bind(name, WlShm, version)
        elif interface == "wl_compositor":
            self.compositor = registry.bind(name, WlCompositor, version)
            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
        elif interface == "zwlr_screencopy_manager_v1":
            self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version)

    def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
                         handle: ZwlrForeignToplevelHandleV1):
        handle.dispatcher["title"] = self.on_title_changed
        handle.dispatcher["app_id"] = self.on_app_id_changed
        handle.dispatcher["output_enter"] = self.on_output_entered
        handle.dispatcher["output_leave"] = self.on_output_left
        handle.dispatcher["state"] = self.on_state_changed
        handle.dispatcher["closed"] = self.on_closed
        self.display.roundtrip()

    def on_output_entered(self, handle, output):
        button = WindowButton(handle, handle.title, self)
        button.icon.set_pixel_size(self.icon_size)
        button.output = None

        if self.show_only_this_output and output != self.my_output:
            return

        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.connect("clicked", self.on_button_click)
        self.set_app_id(button, handle.app_id)
        self.toplevel_buttons[handle] = button
        self.append(button)
        self.set_all_rectangles()

    def on_output_left(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 on_title_changed(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:
            if isinstance(child, WindowButton):
                child.window_id.set_rectangle(self.wl_surface, *get_widget_rect(child))

            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.seat)
        else:
            if button.window_state.focused:
                button.window_id.set_minimized()
            else:
                button.window_id.activate(self.seat)

    def on_state_changed(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)

        self.set_all_rectangles()

    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 on_app_id_changed(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 on_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 output_changed(self):
        self.get_wl_resources()

    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)
