__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 24import typing 25from pathlib import Path 26from pywayland.client import Display, EventQueue 27from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlCompositor, WlOutput 28from pywayland.protocol.wayland.wl_output import WlOutputProxy 29from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import ( 30ZwlrForeignToplevelManagerV1, 31ZwlrForeignToplevelHandleV1 32) 33import panorama_panel 34 35import gi 36 37gi.require_version("Gtk", "4.0") 38gi.require_version("GdkWayland", "4.0") 39 40from gi.repository import Gtk, GLib, Gtk4LayerShell, Gio, Gdk, Pango, GObject 41 42 43module_directory = Path(__file__).resolve().parent 44 45locale.bindtextdomain("panorama-window-list", module_directory / "locale") 46_ = lambda x: locale.dgettext("panorama-window-list", x) 47 48 49import ctypes 50from cffi import FFI 51ffi = FFI() 52ffi.cdef(""" 53void * gdk_wayland_display_get_wl_display (void * display); 54void * gdk_wayland_surface_get_wl_surface (void * surface); 55void * gdk_wayland_monitor_get_wl_output (void * monitor); 56""") 57gtk = ffi.dlopen("libgtk-4.so.1") 58 59 60@Gtk.Template(filename=str(module_directory / "panorama-window-list-options.ui")) 61class WindowListOptions(Gtk.Window): 62__gtype_name__ = "WindowListOptions" 63button_width_adjustment: Gtk.Adjustment = Gtk.Template.Child() 64 65def __init__(self, **kwargs): 66super().__init__(**kwargs) 67 68self.connect("close-request", lambda *args: self.destroy()) 69 70 71def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]: 72if len(array) % size: 73raise ValueError(f"The byte string's length must be a multiple of {size}") 74 75values: list[int] = [] 76for i in range(0, len(array), size): 77values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder)) 78 79return values 80 81 82def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]: 83width = int(widget.get_width()) 84height = int(widget.get_height()) 85 86toplevel = widget.get_root() 87if not toplevel: 88return None, None, width, height 89 90x, y = widget.translate_coordinates(toplevel, 0, 0) 91x = int(x) 92y = int(y) 93 94return x, y, width, height 95 96 97@dataclasses.dataclass 98class WindowState: 99minimised: bool 100maximised: bool 101fullscreen: bool 102focused: bool 103 104@classmethod 105def from_state_array(cls, array: bytes): 106values = split_bytes_into_ints(array) 107instance = cls(False, False, False, False) 108for value in values: 109match value: 110case 0: 111instance.maximised = True 112case 1: 113instance.minimised = True 114case 2: 115instance.focused = True 116case 3: 117instance.fullscreen = True 118 119return instance 120 121 122from gi.repository import Gtk, Gdk 123 124class WindowButtonOptions: 125def __init__(self, max_width: int): 126self.max_width = max_width 127 128class WindowButtonLayoutManager(Gtk.LayoutManager): 129def __init__(self, options: WindowButtonOptions, **kwargs): 130super().__init__(**kwargs) 131self.options = options 132 133def do_measure(self, widget, orientation, for_size): 134child = widget.get_first_child() 135if child is None: 136return 0, 0, 0, 0 137 138if orientation == Gtk.Orientation.HORIZONTAL: 139min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size) 140width = self.options.max_width 141return min_width, width, min_height, nat_height 142else: 143min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size) 144return min_height, nat_height, 0, 0 145 146def do_allocate(self, widget, width, height, baseline): 147child = widget.get_first_child() 148if child is None: 149return 150alloc_width = min(width, self.options.max_width) 151alloc = Gdk.Rectangle() 152alloc.x = 0 153alloc.y = 0 154alloc.width = alloc_width 155alloc.height = height 156child.allocate(alloc.width, alloc.height, baseline) 157 158 159class WindowButton(Gtk.ToggleButton): 160def __init__(self, window_id, window_title, **kwargs): 161super().__init__(**kwargs) 162 163self.window_id: ZwlrForeignToplevelHandleV1 = window_id 164self.wf_ipc_id: typing.Optional[int] = None 165self.set_has_frame(False) 166self.label = Gtk.Label() 167self.icon = Gtk.Image.new_from_icon_name("application-x-executable") 168box = Gtk.Box() 169box.append(self.icon) 170box.append(self.label) 171self.set_child(box) 172 173self.window_title = window_title 174self.window_state = WindowState(False, False, False, False) 175 176self.label.set_ellipsize(Pango.EllipsizeMode.END) 177self.set_hexpand(True) 178self.set_vexpand(True) 179 180self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE) 181self.drag_source.connect("prepare", self.provide_drag_data) 182self.drag_source.connect("drag-begin", self.drag_begin) 183self.drag_source.connect("drag-cancel", self.drag_cancel) 184 185self.add_controller(self.drag_source) 186 187self.menu = Gio.Menu() 188# TODO: toggle the labels when needed 189self.minimise_item = Gio.MenuItem.new(_("_Minimise"), "button.minimise") 190self.maximise_item = Gio.MenuItem.new(_("Ma_ximise"), "button.maximise") 191self.menu.append_item(self.minimise_item) 192self.menu.append_item(self.maximise_item) 193self.menu.append(_("_Close"), "button.close") 194self.menu.append(_("Window list _options"), "applet.options") 195self.popover_menu = Gtk.PopoverMenu.new_from_model(self.menu) 196self.popover_menu.set_parent(self) 197self.popover_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 198self.popover_menu.set_has_arrow(False) 199self.popover_menu.set_halign(Gtk.Align.END) 200 201self.right_click_controller = Gtk.GestureClick(button=3) 202self.right_click_controller.connect("pressed", self.show_menu) 203self.add_controller(self.right_click_controller) 204 205self.action_group = Gio.SimpleActionGroup() 206close_action = Gio.SimpleAction.new("close") 207close_action.connect("activate", self.close_associated) 208self.action_group.insert(close_action) 209minimise_action = Gio.SimpleAction.new("minimise") 210minimise_action.connect("activate", self.minimise_associated) 211self.action_group.insert(minimise_action) 212maximise_action = Gio.SimpleAction.new("maximise") 213maximise_action.connect("activate", self.maximise_associated) 214self.action_group.insert(maximise_action) 215 216self.insert_action_group("button", self.action_group) 217 218self.middle_click_controller = Gtk.GestureClick(button=2) 219self.middle_click_controller.connect("released", self.close_associated) 220self.add_controller(self.middle_click_controller) 221 222def show_menu(self, gesture, n_presses, x, y): 223rect = Gdk.Rectangle() 224rect.x = int(x) 225rect.y = int(y) 226rect.width = 1 227rect.height = 1 228self.popover_menu.popup() 229 230def close_associated(self, *args): 231self.window_id.close() 232 233def minimise_associated(self, action, *args): 234if self.window_state.minimised: 235self.window_id.unset_minimized() 236else: 237self.window_id.set_minimized() 238 239def maximise_associated(self, action, *args): 240if self.window_state.maximised: 241self.window_id.unset_maximized() 242else: 243self.window_id.set_maximized() 244 245def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float): 246app = self.get_root().get_application() 247app.drags[id(self)] = self 248value = GObject.Value() 249value.init(GObject.TYPE_UINT64) 250value.set_uint64(id(self)) 251return Gdk.ContentProvider.new_for_value(value) 252 253def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag): 254paintable = Gtk.WidgetPaintable.new(self).get_current_image() 255source.set_icon(paintable, 0, 0) 256self.hide() 257 258def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason): 259self.show() 260return False 261 262@property 263def window_title(self): 264return self.label.get_text() 265 266@window_title.setter 267def window_title(self, value): 268self.label.set_text(value) 269 270def set_icon_from_app_id(self, app_id): 271app_ids = app_id.split() 272 273# If on Wayfire, find the IPC ID 274for app_id in app_ids: 275if app_id.startswith("wf-ipc-"): 276self.wf_ipc_id = int(app_id.removeprefix("wf-ipc-")) 277break 278 279# Try getting an icon from the correct theme 280icon_theme = Gtk.IconTheme.get_for_display(self.get_display()) 281 282for app_id in app_ids: 283if icon_theme.has_icon(app_id): 284self.icon.set_from_icon_name(app_id) 285return 286 287# If that doesn't work, try getting one from .desktop files 288for app_id in app_ids: 289try: 290desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop") 291if desktop_file: 292self.icon.set_from_gicon(desktop_file.get_icon()) 293return 294except TypeError: 295# Due to a bug, the constructor may sometimes return C NULL 296pass 297 298 299class WFWindowList(panorama_panel.Applet): 300name = _("Wayfire window list") 301description = _("Traditional window list (for Wayfire and other wlroots compositors)") 302 303def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None): 304super().__init__(orientation=orientation, config=config) 305if config is None: 306config = {} 307 308self.set_homogeneous(True) 309self.window_button_options = WindowButtonOptions(config.get("max_button_width", 256)) 310self.show_only_this_wf_workspace = config.get("show_only_this_wf_workspace", True) 311self.show_only_this_output = config.get("show_only_this_output", True) 312 313self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {} 314self.toplevel_buttons_by_wf_id: dict[int, WindowButton] = {} 315# This button doesn't belong to any window but is used for the button group and to be 316# selected when no window is focused 317self.initial_button = Gtk.ToggleButton() 318 319self.display = None 320self.my_output = None 321self.wl_surface_ptr = None 322self.registry = None 323self.compositor = None 324self.seat = None 325 326self.context_menu = self.make_context_menu() 327panorama_panel.track_popover(self.context_menu) 328 329right_click_controller = Gtk.GestureClick() 330right_click_controller.set_button(3) 331right_click_controller.connect("pressed", self.show_context_menu) 332 333self.add_controller(right_click_controller) 334 335action_group = Gio.SimpleActionGroup() 336options_action = Gio.SimpleAction.new("options", None) 337options_action.connect("activate", self.show_options) 338action_group.add_action(options_action) 339self.insert_action_group("applet", action_group) 340# Wait for the widget to be in a layer-shell window before doing this 341self.connect("realize", lambda *args: self.get_wl_resources()) 342 343self.options_window = None 344 345# Support button reordering 346self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE) 347self.drop_target.set_gtypes([GObject.TYPE_UINT64]) 348self.drop_target.connect("drop", self.drop_button) 349 350self.add_controller(self.drop_target) 351 352# Make a Wayfire socket for workspace handling 353try: 354import wayfire 355self.wf_socket = wayfire.WayfireSocket() 356self.wf_socket.watch() 357fd = self.wf_socket.client.fileno() 358GLib.io_add_watch(GLib.IOChannel.unix_new(fd), GLib.IO_IN, self.on_wf_event, priority=GLib.PRIORITY_HIGH) 359except: 360# Wayfire raises Exception itself, so it cannot be narrowed down 361self.wf_socket = None 362 363def on_wf_event(self, source, condition): 364if condition & GLib.IO_IN: 365if not self.show_only_this_wf_workspace: 366return 367try: 368message = self.wf_socket.read_next_event() 369event = message.get("event") 370match event: 371case "view-workspace-changed": 372view = message.get("view", {}) 373output = self.wf_socket.get_output(self.get_root().monitor_index + 1) 374current_workspace = output["workspace"]["x"], output["workspace"]["y"] 375if (message["to"]["x"], message["to"]["y"]) == current_workspace: 376if self.toplevel_buttons_by_wf_id[view["id"]].get_parent() is None: 377self.append(self.toplevel_buttons_by_wf_id[view["id"]]) 378else: 379if self.toplevel_buttons_by_wf_id[view["id"]].get_parent() is self: 380# Remove out-of-workspace window 381self.remove(self.toplevel_buttons_by_wf_id[view["id"]]) 382case "wset-workspace-changed": 383output_id = self.get_root().monitor_index + 1 384if message["wset-data"]["output-id"] == output_id: 385# It has changed on this monitor; refresh the window list 386self.filter_to_wf_workspace() 387 388except Exception as e: 389print("Error reading Wayfire event:", e) 390return True 391 392def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float): 393button: WindowButton = self.get_root().get_application().drags.pop(value) 394if button.get_parent() is not self: 395# Prevent dropping a button from another window list 396return False 397 398self.remove(button) 399# Find the position where to insert the applet 400# Probably we could use the assumption that buttons are homogeneous here for efficiency 401child = self.get_first_child() 402while child: 403allocation = child.get_allocation() 404child_x, child_y = self.translate_coordinates(self, 0, 0) 405if self.get_orientation() == Gtk.Orientation.HORIZONTAL: 406midpoint = child_x + allocation.width / 2 407if x < midpoint: 408button.insert_before(self, child) 409break 410elif self.get_orientation() == Gtk.Orientation.VERTICAL: 411midpoint = child_y + allocation.height / 2 412if y < midpoint: 413button.insert_before(self, child) 414break 415child = child.get_next_sibling() 416else: 417self.append(button) 418button.show() 419 420self.set_all_rectangles() 421return True 422 423def filter_to_wf_workspace(self): 424if not self.show_only_this_wf_workspace: 425return 426 427output = self.wf_socket.get_output(self.get_root().monitor_index + 1) 428for wf_id, button in self.toplevel_buttons_by_wf_id.items(): 429view = self.wf_socket.get_view(wf_id) 430mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2 431mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2 432output_width = output["geometry"]["width"] 433output_height = output["geometry"]["height"] 434if 0 <= mid_x < output_width and 0 <= mid_y < output_height: 435# It is in this workspace; keep it 436if button.get_parent() is None: 437self.append(button) 438else: 439# Remove it from this window list 440if button.get_parent() is self: 441self.remove(button) 442 443def get_wl_resources(self): 444ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 445ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 446 447self.display = Display() 448wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 449ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None))) 450self.display._ptr = wl_display_ptr 451self.event_queue = EventQueue(self.display) 452 453# Intentionally commented: the display is already connected by GTK 454# self.display.connect() 455 456my_monitor = Gtk4LayerShell.get_monitor(self.get_root()) 457 458# Iterate through monitors and get their Wayland output (wl_output) 459# This is a hack to ensure output_enter/leave is called for toplevels 460for monitor in self.get_root().get_native().get_display().get_monitors(): 461wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None))) 462if wl_output: 463print("Create proxy") 464output_proxy = WlOutputProxy(wl_output, self.display) 465output_proxy.interface.registry[output_proxy._ptr] = output_proxy 466 467if monitor == my_monitor: 468self.my_output = output_proxy 469# End hack 470 471self.registry = self.display.get_registry() 472self.registry.dispatcher["global"] = self.on_global 473self.display.roundtrip() 474fd = self.display.get_fd() 475GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event) 476 477if self.wf_socket is not None: 478self.filter_to_wf_workspace() 479 480def on_display_event(self, source, condition): 481if condition & GLib.IO_IN: 482self.display.dispatch(queue=self.event_queue) 483return True 484 485def on_global(self, registry, name, interface, version): 486if interface == "zwlr_foreign_toplevel_manager_v1": 487self.print_log("Interface registered") 488self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version) 489self.manager.dispatcher["toplevel"] = self.on_new_toplevel 490self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished") 491self.display.roundtrip() 492self.display.flush() 493elif interface == "wl_seat": 494self.print_log("Seat found") 495self.seat = registry.bind(name, WlSeat, version) 496elif interface == "wl_compositor": 497self.compositor = registry.bind(name, WlCompositor, version) 498self.wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface( 499ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer( 500self.get_root().get_native().get_surface().__gpointer__, None))) 501 502def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1, 503handle: ZwlrForeignToplevelHandleV1): 504handle.dispatcher["title"] = lambda h, title: self.on_title_changed(h, title) 505handle.dispatcher["app_id"] = lambda h, app_id: self.on_app_id_changed(h, app_id) 506handle.dispatcher["output_enter"] = self.on_output_entered 507handle.dispatcher["output_leave"] = self.on_output_left 508handle.dispatcher["state"] = lambda h, states: self.on_state_changed(h, states) 509handle.dispatcher["closed"] = lambda h: self.on_closed(h) 510 511def on_output_entered(self, handle, output): 512if self.show_only_this_output and output != self.my_output: 513return 514if handle in self.toplevel_buttons: 515button = self.toplevel_buttons[handle] 516self.append(button) 517self.set_all_rectangles() 518 519def on_output_left(self, handle, output): 520if not self.show_only_this_output: 521return 522if output != self.my_output: 523return 524if handle in self.toplevel_buttons: 525button = self.toplevel_buttons[handle] 526self.remove(button) 527self.set_all_rectangles() 528 529def on_title_changed(self, handle, title): 530if handle not in self.toplevel_buttons: 531button = WindowButton(handle, title) 532button.set_group(self.initial_button) 533button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options)) 534button.connect("clicked", self.on_button_click) 535self.toplevel_buttons[handle] = button 536else: 537button = self.toplevel_buttons[handle] 538button.window_title = title 539 540def set_all_rectangles(self): 541child = self.get_first_child() 542while child is not None: 543if isinstance(child, WindowButton): 544surface = WlSurface() 545surface._ptr = self.wl_surface_ptr 546child.window_id.set_rectangle(surface, *get_widget_rect(child)) 547 548child = child.get_next_sibling() 549 550def on_button_click(self, button: WindowButton): 551# Set a rectangle for animation 552surface = WlSurface() 553surface._ptr = self.wl_surface_ptr 554button.window_id.set_rectangle(surface, *get_widget_rect(button)) 555if button.window_state.focused: 556# Already pressed in, so minimise the focused window 557button.window_id.set_minimized() 558else: 559button.window_id.unset_minimized() 560button.window_id.activate(self.seat) 561 562self.display.flush() 563 564def on_state_changed(self, handle, states): 565if handle in self.toplevel_buttons: 566state_info = WindowState.from_state_array(states) 567button = self.toplevel_buttons[handle] 568button.window_state = state_info 569if state_info.focused: 570button.set_active(True) 571else: 572self.initial_button.set_active(True) 573 574self.set_all_rectangles() 575 576def on_app_id_changed(self, handle, app_id): 577if handle in self.toplevel_buttons: 578button = self.toplevel_buttons[handle] 579button.set_icon_from_app_id(app_id) 580app_ids = app_id.split() 581for app_id in app_ids: 582if app_id.startswith("wf-ipc-"): 583self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button 584 585def on_closed(self, handle): 586button: WindowButton = self.toplevel_buttons[handle] 587wf_id = button.wf_ipc_id 588if handle in self.toplevel_buttons: 589self.remove(self.toplevel_buttons[handle]) 590self.toplevel_buttons.pop(handle) 591if wf_id in self.toplevel_buttons_by_wf_id: 592self.toplevel_buttons_by_wf_id.pop(wf_id) 593 594self.set_all_rectangles() 595 596def make_context_menu(self): 597menu = Gio.Menu() 598menu.append(_("Window list _options"), "applet.options") 599context_menu = Gtk.PopoverMenu.new_from_model(menu) 600context_menu.set_has_arrow(False) 601context_menu.set_parent(self) 602context_menu.set_halign(Gtk.Align.START) 603context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 604return context_menu 605 606def show_context_menu(self, gesture, n_presses, x, y): 607rect = Gdk.Rectangle() 608rect.x = int(x) 609rect.y = int(y) 610rect.width = 1 611rect.height = 1 612 613self.context_menu.set_pointing_to(rect) 614self.context_menu.popup() 615 616def show_options(self, _0=None, _1=None): 617if self.options_window is None: 618self.options_window = WindowListOptions() 619self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width) 620self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options) 621 622def reset_window(*args): 623self.options_window = None 624 625self.options_window.connect("close-request", reset_window) 626self.options_window.present() 627 628def update_button_options(self, adjustment): 629self.window_button_options.max_width = adjustment.get_value() 630child: Gtk.Widget = self.get_first_child() 631while child: 632child.queue_allocate() 633child.queue_resize() 634child.queue_draw() 635child = child.get_next_sibling() 636 637self.emit("config-changed") 638 639def get_config(self): 640return { 641"max_button_width": self.window_button_options.max_width, 642"show_only_this_wf_workspace": self.show_only_this_wf_workspace, 643"show_only_this_output": self.show_only_this_output, 644} 645 646def output_changed(self): 647self.get_wl_resources() 648 649def make_draggable(self): 650for button in self.toplevel_buttons.values(): 651button.remove_controller(button.drag_source) 652button.set_sensitive(False) 653 654def restore_drag(self): 655for button in self.toplevel_buttons.values(): 656button.add_controller(button.drag_source) 657button.set_sensitive(True) 658