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