import json from collections.abc import Iterator from fabric.utils import Gdk, get_desktop_applications, idle_add, remove_handler from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.image import Image from fabric.widgets.label import Label from modules.launcher.base import LauncherPlugin from utils.functions import is_special_workspace, run_command class WindowSwitcherPlugin(LauncherPlugin): @property def name(self) -> str: return "windows" @property def icon(self) -> str: return "preferences-system-windows-symbolic" @property def keywords(self) -> list[str]: return ["win", "windows"] def __init__(self, handler): super().__init__(handler) self._query_handler: int = 0 self.windows: list[dict] = [] self._desktop_apps = get_desktop_applications() def on_search(self, text: str) -> None: self.query_windows(text) def stop(self): if self._query_handler: remove_handler(self._query_handler) self._query_handler = 0 def _get_windows(self) -> list[dict]: result = run_command(["hyprctl", "-j", "clients"], timeout=5) if result.returncode != 0 or not isinstance(result.stdout, str): return [] clients = json.loads(result.stdout) filtered_clients = [] for client in clients: if ( not client.get("mapped", False) or not client.get("visible", True) or is_special_workspace(client) ): continue filtered_clients.append(client) return filtered_clients def _get_window_icon_pixbuf(self, window: dict): class_name = window.get("class", "").lower() title = window.get("title", "").lower() for app in self._desktop_apps: app_name = (app.name or "").lower() if ( class_name in app_name or app_name in class_name or any(word in app_name for word in title.split() if len(word) > 2) ): return app.get_icon_pixbuf() return None def _get_workspace_name(self, workspace_id: int) -> str: result = run_command(["hyprctl", "-j", "workspaces"], timeout=3) if result.returncode != 0 or not isinstance(result.stdout, str): return f"Workspace {workspace_id}" workspaces = json.loads(result.stdout) for workspace in workspaces: if workspace.get("id") == workspace_id: return workspace.get("name", f"Workspace {workspace_id}") return f"Workspace {workspace_id}" def query_windows(self, text: str) -> None: self.handler.start("windows") self.windows = self._get_windows() if not self.windows: self._show_no_windows() return filtered_windows = self._filter_windows(text) # We need to check if the iterator is empty, but we can't easily do that without consuming it. # So we'll convert to list first if needed, or just handle the empty case in bake. # However, _filter_windows returns an iterator. Let's modify it to return a list or check emptiness. # Actually, let's just try to bake. If it returns False immediately, we can show no results? # But _bake_next_window_slot calls done() if empty. # Let's peek? No. # Let's just pass it. If it's empty, _bake_next_window_slot will just call done. # But we might want to show "No matching windows" if the filter returns nothing but windows exist. # So let's consume the iterator into a list for checking. filtered_list = list(filtered_windows) if not filtered_list: # If we had windows but none matched, we could show "No results". # But the original code just returned. Let's stick to that or show no results. # Showing no results is better UX. self._show_no_results() return self._query_handler = idle_add( self._bake_next_window_slot, iter(filtered_list), pin=True ) def _filter_windows(self, text: str) -> Iterator[dict]: if not text.strip(): return iter(self.windows) text_lower = text.lower() filtered_windows = [] for window in self.windows: title = window.get("title", "").lower() class_name = window.get("class", "").lower() initial_class = window.get("initialClass", "").lower() if ( text_lower in title or text_lower in class_name or text_lower in initial_class ): filtered_windows.append(window) def sort_key(window): title = window.get("title", "").lower() class_name = window.get("class", "").lower() if text_lower in (title, class_name): return 0 if title.startswith(text_lower) or class_name.startswith(text_lower): return 1 return 2 filtered_windows.sort(key=sort_key) return iter(filtered_windows) def _show_no_windows(self) -> None: self.handler.slot_ready( Button( style_classes="app-slot window-no-windows", child=Box( orientation="h", spacing=12, children=[ Image(icon_name="window-close", size=32), Label( label="No open windows found", style_classes="window-no-windows-text", h_align="center", ), ], ), tooltip_text="No windows are currently open", ), "windows", ) self.handler.done() def _show_no_results(self) -> None: self.handler.slot_ready( Button( style_classes="app-slot window-no-results", child=Box( orientation="h", spacing=12, children=[ Label( label="No windows match your search", style_classes="window-no-results-text", h_align="center", ), ], ), ), "windows", ) self.handler.done() def _bake_next_window_slot(self, iterator: Iterator[dict]) -> bool: window = next(iterator, None) if window is None: idle_add(self.handler.done) return False self._create_window_slot(window) return True def _create_window_slot(self, window: dict) -> None: title = window.get("title", "Unknown Window") class_name = window.get("class", "Unknown") workspace_id = window.get("workspace", {}).get("id", 0) workspace_name = self._get_workspace_name(workspace_id) icon_pixbuf = self._get_window_icon_pixbuf(window) display_title = title[:40] + "..." if len(title) > 40 else title display_class = class_name[:30] + "..." if len(class_name) > 30 else class_name slot_content = Box( orientation="h", spacing=12, children=[ Image(pixbuf=icon_pixbuf, h_align="start", size=32) if icon_pixbuf else Image( icon_name="application-x-executable", h_align="start", size=32 ), Box( orientation="v", children=[ Label( label=f"{display_title} • {workspace_name}", style_classes="window-title", h_align="start", v_align="start", ), Label( label=display_class, style_classes="window-subtitle", h_align="start", v_align="start", ), ], ), ], ) main_content = slot_content slot_widget = Button( style_classes="app-slot window-slot", child=main_content, tooltip_text=f"Enter: Focus window\nShift+Enter: Close window\nTitle: {title}\nClass: {class_name}\nWorkspace: {workspace_name}", on_clicked=lambda *_: self._focus_window(window), ) slot_widget.connect( "key-press-event", lambda widget, event, w=window: self._on_slot_key_press(widget, event, w), ) self.handler.slot_ready(slot_widget, "windows") def _focus_window(self, window: dict) -> None: address = window.get("address") if address: run_command( ["hyprctl", "dispatch", "focuswindow", f"address:{address}"], timeout=3 ) self.handler.launched() def _close_window(self, window: dict) -> None: address = window.get("address") if address: run_command( ["hyprctl", "dispatch", "closewindow", f"address:{address}"], timeout=3 ) def _on_slot_key_press(self, widget, event, window: dict) -> bool: if not event or event.keyval not in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): return False if event.state & Gdk.ModifierType.SHIFT_MASK: self._close_window(window) else: self._focus_window(window) return True def handle_external(self, command: str, args: str) -> None: # Map external commands if needed pass