from collections.abc import Iterator from typing import Dict, Any from utils.wayland import WaylandWindow as Window from fabric.widgets.box import Box from fabric.widgets.entry import Entry from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.widgets.button import Button from fabric.widgets.label import Label from fabric.widgets.image import Image from fabric.utils import get_relative_path, remove_handler, idle_add, DesktopApp from gi.repository import Gdk, GLib, Gtk # Import plugins from .plugins import LauncherPlugin from .plugins.applications import ApplicationsPlugin from .plugins.calculator import CalculatorPlugin from .plugins.web_search import WebSearchPlugin from .plugins.emoji import EmojiPlugin from .plugins.cliphist import CliphistPlugin from .plugins.powermenu import PowerMenuPlugin class Launcher(Window): def __init__(self): super().__init__( name="launcher", layer="top", anchor="center", exclusivity="none", keyboard_mode="on-demand", visible=False, all_visible=False, ) self._arranger_handler: int = 0 self._selected_index = 0 self._results = [] self._active_category = None # No active category by default # Initialize plugins self._plugins = [ ApplicationsPlugin(), CalculatorPlugin(), WebSearchPlugin(), EmojiPlugin(), CliphistPlugin(), PowerMenuPlugin(), ] self.viewport = Box(orientation="v", spacing=2) self.search = Entry( placeholder="Search for apps, files, and more...", h_expand=True, name="launcher-search", ) # Create the apps ScrolledWindow self.apps = ScrolledWindow( spacing=10, min_content_size=(450, 405), max_content_size=(450, 405), child=self.viewport, name="launcher-apps", ) # Add components to the main window self.add( Box( orientation="v", spacing=8, children=[ self.search, self.apps ], ) ) self.show_all() self.hide() self.add_keybinding("Escape", lambda *_: self.close()) # Set up the search callback after all UI components are created self.search.connect("notify::text", lambda entry, *_: self.arrange_viewport(entry.get_text())) # Set up Enter key handling for the search entry self.search.connect("key-press-event", self.on_search_key_press) # Initially hide the scrolled window until search begins self.apps.set_visible(False) def close(self): self.search.set_text("") self._selected_index = 0 self.hide() def on_search_key_press(self, entry, event): """Handle key press events in the search entry""" # Check if Enter or Return key was pressed if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): self.execute_first_result() return True # Event handled return False # Let other handlers process the event def execute_first_result(self): """Execute the action of the first search result if available""" if self._results: first_result = self._results[0] # Execute the action, clear search, and hide launcher first_result["action"]() self.search.set_text("") self.hide() def _get_result_priority(self, result: Dict[str, Any], query: str) -> int: """Get priority score for result sorting (lower = higher priority)""" title = result.get("title", "").lower() query_lower = query.lower() # Priority 100: Generic web search (fallback option) - Check this FIRST if "search the web for" in title.lower(): return 100 # Priority 0: Clipboard results when searching for "clip" or "clipboard" if query_lower in ["clip", "clipboard"] and "clip_id" in result: return 0 # Priority 1: Exact matches (applications, emojis with exact names) if title == query_lower: return 1 # Priority 2: Starts with query (applications, emojis starting with query) if title.startswith(query_lower): return 2 # Priority 3: Contains query (partial matches) if query_lower in title: return 3 # Priority 4: Calculator results (specific functional results) if "=" in title and any(char.isdigit() for char in title): return 4 # Priority 5: Emoji results (specific content matches) if "emoji_icon" in result: return 5 # Priority 6: Application results (specific app matches) if "icon" in result and hasattr(result["icon"], "get_icon_pixbuf"): return 6 # Priority 7: Clipboard results (when not directly searching for clipboard) if "clip_id" in result: return 7 # Priority 8: Other results return 8 def select_next_result(self): if not self._results: return self._selected_index = (self._selected_index + 1) % len(self._results) self.update_selection() def update_selection(self): # Remove focus from all buttons for i, child in enumerate(self.viewport.get_children()): if isinstance(child, Button): if i == self._selected_index: child.grab_focus() else: child.set_state_flags(0, True) def arrange_viewport(self, query: str = ""): remove_handler(self._arranger_handler) if self._arranger_handler else None self._selected_index = 0 self.viewport.children = [] self._results = [] # Show or hide UI elements based on query has_query = bool(query) self.apps.set_visible(has_query) # Get results from all plugins for plugin in self._plugins: plugin_results = plugin.search(query) self._results.extend(plugin_results) # Sort results by relevance (prioritize specific matches over generic ones) self._results.sort(key=lambda result: self._get_result_priority(result, query)) results_iter = iter(self._results) self._arranger_handler = idle_add( lambda *args: self.add_next_result(*args) or self.resize_viewport(), results_iter, pin=True, ) # Make sure search entry maintains focus self.search.grab_focus() return False def update_action_area(self, query: str): self.action_area.children = [] if not query: return # Get action items from all plugins action_items = [] for plugin in self._plugins: action_items.extend(plugin.get_action_items(query)) # Add action items to UI for item in action_items: action_box = Box(orientation="h", name="launcher-action") action_box.add(Image(icon_name=item["icon_name"], name="launcher-action-icon")) action_box.add(Label(label=item["title"], name="launcher-action-label")) # Wrap in a button for clickability action_button = Button( name="launcher-action-button", child=action_box, on_clicked=lambda _, item=item: ( item["action"](), self.search.set_text(""), # Clear search entry when action is executed self.hide(), ), ) self.action_area.add(action_button) self.action_area.show_all() def add_next_result(self, results_iter: Iterator[Dict[str, Any]]): if not (result := next(results_iter, None)): return False button = self.bake_result_slot(result) self.viewport.add(button) # Highlight the first result without stealing focus if self._selected_index == 0 and self.search.get_text(): button.set_state_flags(Gtk.StateFlags.FOCUSED, True) return True def resize_viewport(self): self.apps.set_min_content_width( self.viewport.get_allocation().width # type: ignore ) return False def bake_result_slot(self, result: Dict[str, Any], **kwargs) -> Button: # Create a more spotlight-like result entry if "emoji_icon" in result: # Handle emoji icons - display emoji as text icon = Label( label=result["emoji_icon"], h_align="start", name="launcher-emoji-icon", ) # Set font size for emoji display icon.build().set_markup(f'{result["emoji_icon"]}').unwrap() elif "icon_markup" in result: # Handle icon markup (e.g., power menu icons) icon = Label( markup=result["icon_markup"], h_align="start", name="launcher-power-icon", ) # Set font size and family for icon display icon.build().set_markup(f'{result["icon_markup"]}').unwrap() elif "image_pixbuf" in result: # Handle direct pixbuf images (e.g., clipboard images) icon = Image( pixbuf=result["image_pixbuf"], h_align="start", name="launcher-app-icon", ) elif "icon" in result and isinstance(result["icon"], DesktopApp): # Handle DesktopApp icons icon = Image( pixbuf=result["icon"].get_icon_pixbuf(size=50), h_align="start", name="launcher-app-icon", ) else: # Handle regular icon names icon = Image( icon_name=result.get("icon_name", "application-x-executable-symbolic"), h_align="start", name="launcher-app-icon", ) icon.build().set_pixel_size(50).unwrap() result_box = Box( orientation="h", spacing=12, children=[ icon, Box( orientation="v", spacing=2, children=[ Label( label=result["title"], h_align="start", name="launcher-app-title", ), Label( label=result.get("description", ""), h_align="start", name="launcher-app-description", ), ] ), ], ) button = Button( name="launcher-app", child=result_box, on_enter_notify_event=lambda *_: self.set_cursor("pointer"), on_clicked=lambda *_: ( result["action"](), self.search.set_text(""), # Clear search entry when item is opened self.hide(), ), **kwargs, ) return button