__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, GObject 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 53@Gtk.Template(filename=str(module_directory / "panorama-window-list-options.ui")) 54class WindowListOptions(Gtk.Window): 55__gtype_name__ = "WindowListOptions" 56button_width_adjustment: Gtk.Adjustment = Gtk.Template.Child() 57 58def __init__(self, **kwargs): 59super().__init__(**kwargs) 60 61self.connect("close-request", lambda *args: self.destroy()) 62 63 64def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]: 65if len(array) % size: 66raise ValueError(f"The byte string's length must be a multiple of {size}") 67 68values: list[int] = [] 69for i in range(0, len(array), size): 70values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder)) 71 72return values 73 74 75def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]: 76width = int(widget.get_width()) 77height = int(widget.get_height()) 78 79toplevel = widget.get_root() 80if not toplevel: 81return None, None, width, height 82 83x, y = widget.translate_coordinates(toplevel, 0, 0) 84x = int(x) 85y = int(y) 86 87return x, y, width, height 88 89 90@dataclasses.dataclass 91class WindowState: 92minimised: bool 93maximised: bool 94fullscreen: bool 95focused: bool 96 97@classmethod 98def from_state_array(cls, array: bytes): 99values = split_bytes_into_ints(array) 100instance = cls(False, False, False, False) 101for value in values: 102match value: 103case 0: 104instance.maximised = True 105case 1: 106instance.minimised = True 107case 2: 108instance.focused = True 109case 3: 110instance.fullscreen = True 111 112return instance 113 114 115from gi.repository import Gtk, Gdk 116 117class WindowButtonOptions: 118def __init__(self, max_width: int): 119self.max_width = max_width 120 121class WindowButtonLayoutManager(Gtk.LayoutManager): 122def __init__(self, options: WindowButtonOptions, **kwargs): 123super().__init__(**kwargs) 124self.options = options 125 126def do_measure(self, widget, orientation, for_size): 127child = widget.get_first_child() 128if child is None: 129return 0, 0, 0, 0 130 131if orientation == Gtk.Orientation.HORIZONTAL: 132min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size) 133width = min(nat_width, self.options.max_width) 134return min_width, width, min_height, nat_height 135else: 136min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size) 137return min_height, nat_height, 0, 0 138 139def do_allocate(self, widget, width, height, baseline): 140child = widget.get_first_child() 141if child is None: 142return 143alloc_width = min(width, self.options.max_width) 144alloc = Gdk.Rectangle() 145alloc.x = 0 146alloc.y = 0 147alloc.width = alloc_width 148alloc.height = height 149child.allocate(alloc.width, alloc.height, baseline) 150 151 152class WindowButton(Gtk.ToggleButton): 153def __init__(self, window_id, window_title, **kwargs): 154super().__init__(**kwargs) 155 156self.window_id: ZwlrForeignToplevelHandleV1 = window_id 157self.set_has_frame(False) 158self.label = Gtk.Label() 159self.icon = Gtk.Image.new_from_icon_name("application-x-executable") 160box = Gtk.Box() 161box.append(self.icon) 162box.append(self.label) 163self.set_child(box) 164 165self.window_title = window_title 166self.window_state = WindowState(False, False, False, False) 167 168self.label.set_ellipsize(Pango.EllipsizeMode.END) 169self.set_hexpand(True) 170self.set_vexpand(True) 171 172self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE) 173self.drag_source.connect("prepare", self.provide_drag_data) 174self.drag_source.connect("drag-begin", self.drag_begin) 175self.drag_source.connect("drag-cancel", self.drag_cancel) 176 177self.add_controller(self.drag_source) 178 179def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float): 180app = self.get_root().get_application() 181app.drags[id(self)] = self 182value = GObject.Value() 183value.init(GObject.TYPE_UINT64) 184value.set_uint64(id(self)) 185return Gdk.ContentProvider.new_for_value(value) 186 187def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag): 188paintable = Gtk.WidgetPaintable.new(self).get_current_image() 189source.set_icon(paintable, 0, 0) 190self.hide() 191 192def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason): 193self.show() 194return False 195 196@property 197def window_title(self): 198return self.label.get_text() 199 200@window_title.setter 201def window_title(self, value): 202self.label.set_text(value) 203 204def set_icon_from_app_id(self, app_id): 205# Try getting an icon from the correct theme 206app_ids = app_id.split() 207icon_theme = Gtk.IconTheme.get_for_display(self.get_display()) 208 209for app_id in app_ids: 210if icon_theme.has_icon(app_id): 211self.icon.set_from_icon_name(app_id) 212return 213 214# If that doesn't work, try getting one from .desktop files 215for app_id in app_ids: 216try: 217desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop") 218if desktop_file: 219self.icon.set_from_gicon(desktop_file.get_icon()) 220return 221except TypeError: 222# Due to a bug, the constructor may sometimes return C NULL 223pass 224 225 226class WFWindowList(panorama_panel.Applet): 227name = "Wayfire window list" 228description = "Traditional window list (for Wayfire)" 229 230def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None): 231super().__init__(orientation=orientation, config=config) 232if config is None: 233config = {} 234 235self.set_homogeneous(True) 236self.window_button_options = WindowButtonOptions(240) 237 238self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {} 239# This button doesn't belong to any window but is used for the button group and to be 240# selected when no window is focused 241self.initial_button = Gtk.ToggleButton() 242 243self.display = None 244self.wl_surface_ptr = None 245self.registry = None 246self.compositor = None 247self.seat = None 248 249self.context_menu = self.make_context_menu() 250panorama_panel.track_popover(self.context_menu) 251 252right_click_controller = Gtk.GestureClick() 253right_click_controller.set_button(3) 254right_click_controller.connect("pressed", self.show_context_menu) 255 256self.add_controller(right_click_controller) 257 258action_group = Gio.SimpleActionGroup() 259options_action = Gio.SimpleAction.new("options", None) 260options_action.connect("activate", self.show_options) 261action_group.add_action(options_action) 262self.insert_action_group("applet", action_group) 263# Wait for the widget to be in a layer-shell window before doing this 264self.connect("realize", lambda *args: self.get_wl_resources()) 265 266self.options_window = None 267 268# Support button reordering 269self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE) 270self.drop_target.set_gtypes([GObject.TYPE_UINT64]) 271self.drop_target.connect("drop", self.drop_button) 272 273self.add_controller(self.drop_target) 274 275def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float): 276button: WindowButton = self.get_root().get_application().drags.pop(value) 277if button.get_parent() is not self: 278# Prevent dropping a button from another window list 279return False 280 281self.remove(button) 282# Find the position where to insert the applet 283# Probably we could use the assumption that buttons are homogeneous here for efficiency 284child = self.get_first_child() 285while child: 286allocation = child.get_allocation() 287child_x, child_y = self.translate_coordinates(self, 0, 0) 288if self.get_orientation() == Gtk.Orientation.HORIZONTAL: 289midpoint = child_x + allocation.width / 2 290if x < midpoint: 291button.insert_before(self, child) 292break 293elif self.get_orientation() == Gtk.Orientation.VERTICAL: 294midpoint = child_y + allocation.height / 2 295if y < midpoint: 296button.insert_before(self, child) 297break 298child = child.get_next_sibling() 299else: 300self.append(button) 301button.show() 302 303self.set_all_rectangles() 304return True 305 306def get_wl_resources(self): 307ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 308ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 309 310self.display = Display() 311wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 312ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None))) 313self.display._ptr = wl_display_ptr 314 315# Intentionally commented: the display is already connected by GTK 316# self.display.connect() 317 318self.registry = self.display.get_registry() 319self.registry.dispatcher["global"] = self.on_global 320self.display.roundtrip() 321fd = self.display.get_fd() 322GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event) 323 324def on_display_event(self, source, condition): 325if condition == GLib.IO_IN: 326self.display.dispatch(block=True) 327return True 328 329def on_global(self, registry, name, interface, version): 330if interface == "zwlr_foreign_toplevel_manager_v1": 331self.print_log("Interface registered") 332self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version) 333self.manager.dispatcher["toplevel"] = self.on_new_toplevel 334self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished") 335self.display.roundtrip() 336self.display.flush() 337elif interface == "wl_seat": 338self.print_log("Seat found") 339self.seat = registry.bind(name, WlSeat, version) 340elif interface == "wl_compositor": 341self.compositor = registry.bind(name, WlCompositor, version) 342self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface( 343ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer( 344self.get_root().get_native().get_surface().__gpointer__, None))) 345 346def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1, 347handle: ZwlrForeignToplevelHandleV1): 348handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title) 349handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id) 350handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states) 351handle.dispatcher["closed"] = lambda h: self.on_closed(h) 352 353def on_title_changed(self, handle, title): 354if handle not in self.toplevel_buttons: 355button = WindowButton(handle, title) 356button.set_group(self.initial_button) 357button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options)) 358button.connect("clicked", self.on_button_click) 359self.toplevel_buttons[handle] = button 360self.append(button) 361else: 362button = self.toplevel_buttons[handle] 363button.window_title = title 364 365self.set_all_rectangles() 366 367def set_all_rectangles(self): 368for button in self.toplevel_buttons.values(): 369surface = WlSurface() 370surface._ptr = self.wl_surface_ptr 371button.window_id.set_rectangle(surface, *get_widget_rect(button)) 372 373def on_button_click(self, button: WindowButton): 374# Set a rectangle for animation 375surface = WlSurface() 376surface._ptr = self.wl_surface_ptr 377button.window_id.set_rectangle(surface, *get_widget_rect(button)) 378if button.window_state.focused: 379# Already pressed in, so minimise the focused window 380button.window_id.set_minimized() 381else: 382button.window_id.unset_minimized() 383button.window_id.activate(self.seat) 384 385self.display.flush() 386 387def on_state_changed(self, handle, states): 388if handle in self.toplevel_buttons: 389state_info = WindowState.from_state_array(states) 390button = self.toplevel_buttons[handle] 391button.window_state = state_info 392if state_info.focused: 393button.set_active(True) 394else: 395self.initial_button.set_active(True) 396 397self.set_all_rectangles() 398 399def on_app_id_changed(self, handle, app_id): 400if handle in self.toplevel_buttons: 401button = self.toplevel_buttons[handle] 402button.set_icon_from_app_id(app_id) 403 404def on_closed(self, handle): 405if handle in self.toplevel_buttons: 406self.remove(self.toplevel_buttons[handle]) 407self.toplevel_buttons.pop(handle) 408 409self.set_all_rectangles() 410 411def make_context_menu(self): 412menu = Gio.Menu() 413menu.append("Window list _options", "applet.options") 414context_menu = Gtk.PopoverMenu.new_from_model(menu) 415context_menu.set_has_arrow(False) 416context_menu.set_parent(self) 417context_menu.set_halign(Gtk.Align.START) 418context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 419return context_menu 420 421def show_context_menu(self, gesture, n_presses, x, y): 422rect = Gdk.Rectangle() 423rect.x = int(x) 424rect.y = int(y) 425rect.width = 1 426rect.height = 1 427 428self.context_menu.set_pointing_to(rect) 429self.context_menu.popup() 430 431def show_options(self, _0=None, _1=None): 432if self.options_window is None: 433self.options_window = WindowListOptions() 434self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width) 435self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options) 436 437def reset_window(*args): 438self.options_window = None 439 440self.options_window.connect("close-request", reset_window) 441self.options_window.present() 442 443def update_button_options(self, adjustment): 444self.window_button_options.max_width = adjustment.get_value() 445child: Gtk.Widget = self.get_first_child() 446while child: 447child.queue_allocate() 448child.queue_resize() 449child.queue_draw() 450child = child.get_next_sibling() 451 452def get_config(self): 453return {}