__init__.py
Python script, ASCII text executable
1""" 2Traditional window list applet for the Panorama panel, compatible 3with wlroots-based compositors. 4Copyright 2025, roundabout-host.com <vlad@roundabout-host.com> 5 6This program is free software: you can redistribute it and/or modify 7it under the terms of the GNU General Public Licence as published by 8the Free Software Foundation, either version 3 of the Licence, or 9(at your option) any later version. 10 11This program is distributed in the hope that it will be useful, 12but WITHOUT ANY WARRANTY; without even the implied warranty of 13MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14GNU General Public Licence for more details. 15 16You should have received a copy of the GNU General Public Licence 17along with this program. If not, see <https://www.gnu.org/licenses/>. 18""" 19 20import dataclasses 21import os 22import sys 23import locale 24from pathlib import Path 25from pywayland.client import Display 26from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlCompositor, WlOutput 27from pywayland.protocol.wayland.wl_output import WlOutputProxy 28from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import ( 29ZwlrForeignToplevelManagerV1, 30ZwlrForeignToplevelHandleV1 31) 32import panorama_panel 33 34import gi 35 36gi.require_version("Gtk", "4.0") 37gi.require_version("GdkWayland", "4.0") 38 39from gi.repository import Gtk, GLib, Gtk4LayerShell, Gio, Gdk, Pango, GObject 40 41 42module_directory = Path(__file__).resolve().parent 43 44locale.bindtextdomain("panorama-window-list", module_directory / "locale") 45_ = lambda x: locale.dgettext("panorama-window-list", x) 46 47 48import ctypes 49from cffi import FFI 50ffi = FFI() 51ffi.cdef(""" 52void * gdk_wayland_display_get_wl_display (void * display); 53void * gdk_wayland_surface_get_wl_surface (void * surface); 54void * gdk_wayland_monitor_get_wl_output (void * monitor); 55""") 56gtk = ffi.dlopen("libgtk-4.so.1") 57 58 59@Gtk.Template(filename=str(module_directory / "panorama-window-list-options.ui")) 60class WindowListOptions(Gtk.Window): 61__gtype_name__ = "WindowListOptions" 62button_width_adjustment: Gtk.Adjustment = Gtk.Template.Child() 63 64def __init__(self, **kwargs): 65super().__init__(**kwargs) 66 67self.connect("close-request", lambda *args: self.destroy()) 68 69 70def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]: 71if len(array) % size: 72raise ValueError(f"The byte string's length must be a multiple of {size}") 73 74values: list[int] = [] 75for i in range(0, len(array), size): 76values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder)) 77 78return values 79 80 81def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]: 82width = int(widget.get_width()) 83height = int(widget.get_height()) 84 85toplevel = widget.get_root() 86if not toplevel: 87return None, None, width, height 88 89x, y = widget.translate_coordinates(toplevel, 0, 0) 90x = int(x) 91y = int(y) 92 93return x, y, width, height 94 95 96@dataclasses.dataclass 97class WindowState: 98minimised: bool 99maximised: bool 100fullscreen: bool 101focused: bool 102 103@classmethod 104def from_state_array(cls, array: bytes): 105values = split_bytes_into_ints(array) 106instance = cls(False, False, False, False) 107for value in values: 108match value: 109case 0: 110instance.maximised = True 111case 1: 112instance.minimised = True 113case 2: 114instance.focused = True 115case 3: 116instance.fullscreen = True 117 118return instance 119 120 121from gi.repository import Gtk, Gdk 122 123class WindowButtonOptions: 124def __init__(self, max_width: int): 125self.max_width = max_width 126 127class WindowButtonLayoutManager(Gtk.LayoutManager): 128def __init__(self, options: WindowButtonOptions, **kwargs): 129super().__init__(**kwargs) 130self.options = options 131 132def do_measure(self, widget, orientation, for_size): 133child = widget.get_first_child() 134if child is None: 135return 0, 0, 0, 0 136 137if orientation == Gtk.Orientation.HORIZONTAL: 138min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size) 139width = min(nat_width, self.options.max_width) 140return min_width, width, min_height, nat_height 141else: 142min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size) 143return min_height, nat_height, 0, 0 144 145def do_allocate(self, widget, width, height, baseline): 146child = widget.get_first_child() 147if child is None: 148return 149alloc_width = min(width, self.options.max_width) 150alloc = Gdk.Rectangle() 151alloc.x = 0 152alloc.y = 0 153alloc.width = alloc_width 154alloc.height = height 155child.allocate(alloc.width, alloc.height, baseline) 156 157 158class WindowButton(Gtk.ToggleButton): 159def __init__(self, window_id, window_title, **kwargs): 160super().__init__(**kwargs) 161 162self.window_id: ZwlrForeignToplevelHandleV1 = window_id 163self.set_has_frame(False) 164self.label = Gtk.Label() 165self.icon = Gtk.Image.new_from_icon_name("application-x-executable") 166box = Gtk.Box() 167box.append(self.icon) 168box.append(self.label) 169self.set_child(box) 170 171self.window_title = window_title 172self.window_state = WindowState(False, False, False, False) 173 174self.label.set_ellipsize(Pango.EllipsizeMode.END) 175self.set_hexpand(True) 176self.set_vexpand(True) 177 178self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE) 179self.drag_source.connect("prepare", self.provide_drag_data) 180self.drag_source.connect("drag-begin", self.drag_begin) 181self.drag_source.connect("drag-cancel", self.drag_cancel) 182 183self.add_controller(self.drag_source) 184 185def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float): 186app = self.get_root().get_application() 187app.drags[id(self)] = self 188value = GObject.Value() 189value.init(GObject.TYPE_UINT64) 190value.set_uint64(id(self)) 191return Gdk.ContentProvider.new_for_value(value) 192 193def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag): 194paintable = Gtk.WidgetPaintable.new(self).get_current_image() 195source.set_icon(paintable, 0, 0) 196self.hide() 197 198def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason): 199self.show() 200return False 201 202@property 203def window_title(self): 204return self.label.get_text() 205 206@window_title.setter 207def window_title(self, value): 208self.label.set_text(value) 209 210def set_icon_from_app_id(self, app_id): 211# Try getting an icon from the correct theme 212app_ids = app_id.split() 213icon_theme = Gtk.IconTheme.get_for_display(self.get_display()) 214 215for app_id in app_ids: 216if icon_theme.has_icon(app_id): 217self.icon.set_from_icon_name(app_id) 218return 219 220# If that doesn't work, try getting one from .desktop files 221for app_id in app_ids: 222try: 223desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop") 224if desktop_file: 225self.icon.set_from_gicon(desktop_file.get_icon()) 226return 227except TypeError: 228# Due to a bug, the constructor may sometimes return C NULL 229pass 230 231 232class WFWindowList(panorama_panel.Applet): 233name = _("Wayfire window list") 234description = _("Traditional window list (for Wayfire and other wlroots compositors)") 235 236def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None): 237super().__init__(orientation=orientation, config=config) 238if config is None: 239config = {} 240 241self.set_homogeneous(True) 242self.window_button_options = WindowButtonOptions(config.get("max_button_width", 256)) 243 244self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {} 245# This button doesn't belong to any window but is used for the button group and to be 246# selected when no window is focused 247self.initial_button = Gtk.ToggleButton() 248 249self.display = None 250self.my_output = None 251self.wl_surface_ptr = None 252self.registry = None 253self.compositor = None 254self.seat = None 255 256self.context_menu = self.make_context_menu() 257panorama_panel.track_popover(self.context_menu) 258 259right_click_controller = Gtk.GestureClick() 260right_click_controller.set_button(3) 261right_click_controller.connect("pressed", self.show_context_menu) 262 263self.add_controller(right_click_controller) 264 265action_group = Gio.SimpleActionGroup() 266options_action = Gio.SimpleAction.new("options", None) 267options_action.connect("activate", self.show_options) 268action_group.add_action(options_action) 269self.insert_action_group("applet", action_group) 270# Wait for the widget to be in a layer-shell window before doing this 271self.connect("realize", lambda *args: self.get_wl_resources()) 272 273self.options_window = None 274 275# Support button reordering 276self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE) 277self.drop_target.set_gtypes([GObject.TYPE_UINT64]) 278self.drop_target.connect("drop", self.drop_button) 279 280self.add_controller(self.drop_target) 281 282def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float): 283button: WindowButton = self.get_root().get_application().drags.pop(value) 284if button.get_parent() is not self: 285# Prevent dropping a button from another window list 286return False 287 288self.remove(button) 289# Find the position where to insert the applet 290# Probably we could use the assumption that buttons are homogeneous here for efficiency 291child = self.get_first_child() 292while child: 293allocation = child.get_allocation() 294child_x, child_y = self.translate_coordinates(self, 0, 0) 295if self.get_orientation() == Gtk.Orientation.HORIZONTAL: 296midpoint = child_x + allocation.width / 2 297if x < midpoint: 298button.insert_before(self, child) 299break 300elif self.get_orientation() == Gtk.Orientation.VERTICAL: 301midpoint = child_y + allocation.height / 2 302if y < midpoint: 303button.insert_before(self, child) 304break 305child = child.get_next_sibling() 306else: 307self.append(button) 308button.show() 309 310self.set_all_rectangles() 311return True 312 313def get_wl_resources(self): 314ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 315ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 316 317self.display = Display() 318wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 319ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None))) 320self.display._ptr = wl_display_ptr 321 322# Intentionally commented: the display is already connected by GTK 323# self.display.connect() 324 325my_monitor = Gtk4LayerShell.get_monitor(self.get_root()) 326 327# Iterate through monitors and get their Wayland output (wl_output) 328# This is a hack to ensure output_enter/leave is called for toplevels 329for monitor in self.get_root().get_native().get_display().get_monitors(): 330wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None))) 331if wl_output: 332print("Create proxy") 333output_proxy = WlOutputProxy(wl_output, self.display) 334output_proxy.interface.registry[output_proxy._ptr] = output_proxy 335 336if monitor == my_monitor: 337self.my_output = output_proxy 338# End hack 339 340self.registry = self.display.get_registry() 341self.registry.dispatcher["global"] = self.on_global 342self.display.roundtrip() 343fd = self.display.get_fd() 344GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event) 345 346def on_display_event(self, source, condition): 347if condition == GLib.IO_IN: 348self.display.dispatch(block=True) 349return True 350 351def on_global(self, registry, name, interface, version): 352if interface == "zwlr_foreign_toplevel_manager_v1": 353self.print_log("Interface registered") 354self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version) 355self.manager.dispatcher["toplevel"] = self.on_new_toplevel 356self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished") 357self.display.roundtrip() 358self.display.flush() 359elif interface == "wl_seat": 360self.print_log("Seat found") 361self.seat = registry.bind(name, WlSeat, version) 362elif interface == "wl_compositor": 363self.compositor = registry.bind(name, WlCompositor, version) 364self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface( 365ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer( 366self.get_root().get_native().get_surface().__gpointer__, None))) 367 368def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1, 369handle: ZwlrForeignToplevelHandleV1): 370handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title) 371handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id) 372handle.dispatcher["output_enter"] = self.on_output_entered 373handle.dispatcher["output_leave"] = self.on_output_left 374handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states) 375handle.dispatcher["closed"] = lambda h: self.on_closed(h) 376 377def on_output_entered(self, handle, output): 378# TODO: make this configurable 379if output != self.my_output: 380return 381if handle in self.toplevel_buttons: 382button = self.toplevel_buttons[handle] 383self.append(button) 384self.set_all_rectangles() 385 386def on_output_left(self, handle, output): 387if output != self.my_output: 388return 389if handle in self.toplevel_buttons: 390button = self.toplevel_buttons[handle] 391self.remove(button) 392self.set_all_rectangles() 393 394def on_title_changed(self, handle, title): 395if handle not in self.toplevel_buttons: 396button = WindowButton(handle, title) 397button.set_group(self.initial_button) 398button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options)) 399button.connect("clicked", self.on_button_click) 400self.toplevel_buttons[handle] = button 401else: 402button = self.toplevel_buttons[handle] 403button.window_title = title 404 405def set_all_rectangles(self): 406child = self.get_first_child() 407while child is not None: 408if isinstance(child, WindowButton): 409surface = WlSurface() 410surface._ptr = self.wl_surface_ptr 411child.window_id.set_rectangle(surface, *get_widget_rect(child)) 412 413child = child.get_next_sibling() 414 415def on_button_click(self, button: WindowButton): 416# Set a rectangle for animation 417surface = WlSurface() 418surface._ptr = self.wl_surface_ptr 419button.window_id.set_rectangle(surface, *get_widget_rect(button)) 420if button.window_state.focused: 421# Already pressed in, so minimise the focused window 422button.window_id.set_minimized() 423else: 424button.window_id.unset_minimized() 425button.window_id.activate(self.seat) 426 427self.display.flush() 428 429def on_state_changed(self, handle, states): 430if handle in self.toplevel_buttons: 431state_info = WindowState.from_state_array(states) 432button = self.toplevel_buttons[handle] 433button.window_state = state_info 434if state_info.focused: 435button.set_active(True) 436else: 437self.initial_button.set_active(True) 438 439self.set_all_rectangles() 440 441def on_app_id_changed(self, handle, app_id): 442if handle in self.toplevel_buttons: 443button = self.toplevel_buttons[handle] 444button.set_icon_from_app_id(app_id) 445 446def on_closed(self, handle): 447if handle in self.toplevel_buttons: 448self.remove(self.toplevel_buttons[handle]) 449self.toplevel_buttons.pop(handle) 450 451self.set_all_rectangles() 452 453def make_context_menu(self): 454menu = Gio.Menu() 455menu.append(_("Window list _options"), "applet.options") 456context_menu = Gtk.PopoverMenu.new_from_model(menu) 457context_menu.set_has_arrow(False) 458context_menu.set_parent(self) 459context_menu.set_halign(Gtk.Align.START) 460context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 461return context_menu 462 463def show_context_menu(self, gesture, n_presses, x, y): 464rect = Gdk.Rectangle() 465rect.x = int(x) 466rect.y = int(y) 467rect.width = 1 468rect.height = 1 469 470self.context_menu.set_pointing_to(rect) 471self.context_menu.popup() 472 473def show_options(self, _0=None, _1=None): 474if self.options_window is None: 475self.options_window = WindowListOptions() 476self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width) 477self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options) 478 479def reset_window(*args): 480self.options_window = None 481 482self.options_window.connect("close-request", reset_window) 483self.options_window.present() 484 485def update_button_options(self, adjustment): 486self.window_button_options.max_width = adjustment.get_value() 487child: Gtk.Widget = self.get_first_child() 488while child: 489child.queue_allocate() 490child.queue_resize() 491child.queue_draw() 492child = child.get_next_sibling() 493 494def get_config(self): 495return {"max_button_width": self.window_button_options.max_width} 496 497def output_changed(self): 498self.get_wl_resources() 499