Files
Modus/modules/launcher/main.py
T
2025-06-13 21:00:45 +05:45

337 lines
11 KiB
Python

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'<span font_size="24000">{result["emoji_icon"]}</span>').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'<span font_family="tabler-icons" font_size="32000">{result["icon_markup"]}</span>').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