__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 23from pathlib import Path 24from pywayland.client import Display 25from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlCompositor 26from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import ( 27ZwlrForeignToplevelManagerV1, 28ZwlrForeignToplevelHandleV1 29) 30import panorama_panel 31 32import gi 33 34gi.require_version("Gtk", "4.0") 35gi.require_version("GdkWayland", "4.0") 36 37from gi.repository import Gtk, GLib, Gtk4LayerShell, Gio, Gdk, Pango 38 39 40import ctypes 41from cffi import FFI 42ffi = FFI() 43ffi.cdef(""" 44void * gdk_wayland_display_get_wl_display (void * display); 45void * gdk_wayland_surface_get_wl_surface (void * surface); 46""") 47gtk = ffi.dlopen("libgtk-4.so.1") 48 49 50module_directory = Path(__file__).resolve().parent 51 52 53def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]: 54if len(array) % size: 55raise ValueError(f"The byte string's length must be a multiple of {size}") 56 57values: list[int] = [] 58for i in range(0, len(array), size): 59values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder)) 60 61return values 62 63 64def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]: 65width = int(widget.get_width()) 66height = int(widget.get_height()) 67 68toplevel = widget.get_root() 69if not toplevel: 70return None, None, width, height 71 72x, y = widget.translate_coordinates(toplevel, 0, 0) 73x = int(x) 74y = int(y) 75 76return x, y, width, height 77 78 79@dataclasses.dataclass 80class WindowState: 81minimised: bool 82maximised: bool 83fullscreen: bool 84focused: bool 85 86@classmethod 87def from_state_array(cls, array: bytes): 88values = split_bytes_into_ints(array) 89instance = cls(False, False, False, False) 90for value in values: 91match value: 92case 0: 93instance.maximised = True 94case 1: 95instance.minimised = True 96case 2: 97instance.focused = True 98case 3: 99instance.fullscreen = True 100 101return instance 102 103 104from gi.repository import Gtk, Gdk 105 106class WindowButtonOptions: 107def __init__(self, max_width: int): 108self.max_width = max_width 109 110class WindowButtonLayoutManager(Gtk.LayoutManager): 111def __init__(self, options: WindowButtonOptions, **kwargs): 112super().__init__(**kwargs) 113self.options = options 114 115def do_measure(self, widget, orientation, for_size): 116child = widget.get_first_child() 117if child is None: 118return 0, 0, 0, 0 119 120if orientation == Gtk.Orientation.HORIZONTAL: 121min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size) 122width = min(nat_width, self.options.max_width) 123return min_width, width, min_height, nat_height 124else: 125min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size) 126return min_height, nat_height, 0, 0 127 128def do_allocate(self, widget, width, height, baseline): 129child = widget.get_first_child() 130if child is None: 131return 132alloc_width = min(width, self.options.max_width) 133alloc = Gdk.Rectangle() 134alloc.x = 0 135alloc.y = 0 136alloc.width = alloc_width 137alloc.height = height 138child.allocate(alloc.width, alloc.height, baseline) 139 140 141class WindowButton(Gtk.ToggleButton): 142def __init__(self, window_id, window_title, **kwargs): 143super().__init__(**kwargs) 144 145self.window_id: ZwlrForeignToplevelHandleV1 = window_id 146self.set_has_frame(False) 147self.label = Gtk.Label() 148self.icon = Gtk.Image.new_from_icon_name("application-x-executable") 149box = Gtk.Box() 150box.append(self.icon) 151box.append(self.label) 152self.set_child(box) 153 154self.window_title = window_title 155self.window_state = WindowState(False, False, False, False) 156 157self.label.set_ellipsize(Pango.EllipsizeMode.END) 158self.set_hexpand(True) 159self.set_vexpand(True) 160 161@property 162def window_title(self): 163return self.label.get_text() 164 165@window_title.setter 166def window_title(self, value): 167self.label.set_text(value) 168 169def set_icon_from_app_id(self, app_id): 170# Try getting an icon from the correct theme 171app_ids = app_id.split() 172icon_theme = Gtk.IconTheme.get_for_display(self.get_display()) 173 174for app_id in app_ids: 175if icon_theme.has_icon(app_id): 176self.icon.set_from_icon_name(app_id) 177return 178 179# If that doesn't work, try getting one from .desktop files 180for app_id in app_ids: 181try: 182desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop") 183if desktop_file: 184self.icon.set_from_gicon(desktop_file.get_icon()) 185return 186except TypeError: 187# Due to a bug, the constructor may sometimes return C NULL 188pass 189 190 191class WFWindowList(panorama_panel.Applet): 192name = "Wayfire window list" 193description = "Traditional window list (for Wayfire)" 194 195def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None): 196super().__init__(orientation=orientation, config=config) 197if config is None: 198config = {} 199 200self.set_homogeneous(True) 201self.window_button_options = WindowButtonOptions(240) 202 203self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {} 204# This button doesn't belong to any window but is used for the button group and to be 205# selected when no window is focused 206self.initial_button = Gtk.ToggleButton() 207 208self.display = None 209self.wl_surface_ptr = None 210self.registry = None 211self.compositor = None 212self.seat = None 213 214self.context_menu = self.make_context_menu() 215panorama_panel.track_popover(self.context_menu) 216 217right_click_controller = Gtk.GestureClick() 218right_click_controller.set_button(3) 219right_click_controller.connect("pressed", self.show_context_menu) 220 221self.add_controller(right_click_controller) 222 223action_group = Gio.SimpleActionGroup() 224options_action = Gio.SimpleAction.new("options", None) 225options_action.connect("activate", self.show_options) 226action_group.add_action(options_action) 227self.insert_action_group("applet", action_group) 228# Wait for the widget to be in a layer-shell window before doing this 229self.connect("realize", lambda *args: self.get_wl_resources()) 230 231self.options_window = None 232 233def get_wl_resources(self): 234ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 235ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 236 237self.display = Display() 238wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 239ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None))) 240self.display._ptr = wl_display_ptr 241 242# Intentionally commented: the display is already connected by GTK 243# self.display.connect() 244 245self.registry = self.display.get_registry() 246self.registry.dispatcher["global"] = self.on_global 247self.display.roundtrip() 248fd = self.display.get_fd() 249GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event) 250 251def on_display_event(self, source, condition): 252if condition == GLib.IO_IN: 253self.display.dispatch(block=True) 254return True 255 256def on_global(self, registry, name, interface, version): 257if interface == "zwlr_foreign_toplevel_manager_v1": 258self.print_log("Interface registered") 259self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version) 260self.manager.dispatcher["toplevel"] = self.on_new_toplevel 261self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished") 262self.display.roundtrip() 263self.display.flush() 264elif interface == "wl_seat": 265self.print_log("Seat found") 266self.seat = registry.bind(name, WlSeat, version) 267elif interface == "wl_compositor": 268self.compositor = registry.bind(name, WlCompositor, version) 269self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface( 270ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer( 271self.get_root().get_native().get_surface().__gpointer__, None))) 272 273def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1, 274handle: ZwlrForeignToplevelHandleV1): 275handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title) 276handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id) 277handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states) 278handle.dispatcher["closed"] = lambda h: self.on_closed(h) 279 280def on_title_changed(self, handle, title): 281if handle not in self.toplevel_buttons: 282button = WindowButton(handle, title) 283button.set_group(self.initial_button) 284button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options)) 285button.connect("clicked", self.on_button_click) 286self.toplevel_buttons[handle] = button 287self.append(button) 288else: 289button = self.toplevel_buttons[handle] 290button.window_title = title 291 292self.set_all_rectangles() 293 294def set_all_rectangles(self): 295for button in self.toplevel_buttons.values(): 296surface = WlSurface() 297surface._ptr = self.wl_surface_ptr 298button.window_id.set_rectangle(surface, *get_widget_rect(button)) 299 300def on_button_click(self, button: WindowButton): 301# Set a rectangle for animation 302surface = WlSurface() 303surface._ptr = self.wl_surface_ptr 304button.window_id.set_rectangle(surface, *get_widget_rect(button)) 305if button.window_state.focused: 306# Already pressed in, so minimise the focused window 307button.window_id.set_minimized() 308else: 309button.window_id.unset_minimized() 310button.window_id.activate(self.seat) 311 312self.display.flush() 313 314def on_state_changed(self, handle, states): 315if handle in self.toplevel_buttons: 316state_info = WindowState.from_state_array(states) 317button = self.toplevel_buttons[handle] 318button.window_state = state_info 319if state_info.focused: 320button.set_active(True) 321else: 322self.initial_button.set_active(True) 323 324self.set_all_rectangles() 325 326def on_app_id_changed(self, handle, app_id): 327if handle in self.toplevel_buttons: 328button = self.toplevel_buttons[handle] 329button.set_icon_from_app_id(app_id) 330 331def on_closed(self, handle): 332if handle in self.toplevel_buttons: 333self.remove(self.toplevel_buttons[handle]) 334self.toplevel_buttons.pop(handle) 335 336self.set_all_rectangles() 337 338def make_context_menu(self): 339menu = Gio.Menu() 340menu.append("Window list _options", "applet.options") 341context_menu = Gtk.PopoverMenu.new_from_model(menu) 342context_menu.set_has_arrow(False) 343context_menu.set_parent(self) 344context_menu.set_halign(Gtk.Align.START) 345context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 346return context_menu 347 348def show_context_menu(self, gesture, n_presses, x, y): 349rect = Gdk.Rectangle() 350rect.x = int(x) 351rect.y = int(y) 352rect.width = 1 353rect.height = 1 354 355self.context_menu.set_pointing_to(rect) 356self.context_menu.popup() 357 358def show_options(self, _0=None, _1=None): 359pass 360 361def get_config(self): 362return {}