Files
Modus/modules/launcher/main.py
T
2026-02-28 19:40:01 +05:45

1381 lines
55 KiB
Python

from typing import List, Optional, Tuple
from fabric.core.service import Property
from fabric.widgets.box import Box
from fabric.widgets.entry import Entry
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import Gdk, GLib
from modules.launcher.plugin_manager import PluginManager
from modules.launcher.result import Result
from modules.launcher.result_item import ResultItem
from modules.launcher.trigger_config import TriggerConfig
# Constants
SEARCH_DEBOUNCE_MS = 50
TRIGGER_SEARCH_DEBOUNCE_MS = 50
CURSOR_POSITION_DELAY_MS = 10
SCROLL_PADDING = 10
DEFAULT_ITEM_HEIGHT = 68
PAGE_NAVIGATION_STEP = 5
LAUNCHER_WIDTH = 640
LAUNCHER_HEIGHT = 400
class Launcher(Window):
"""
Main launcher window with search functionality and plugin system.
Spotlight-style interface.
"""
# Properties
query = Property(str, flags="read-write", default_value="")
visible = Property(bool, flags="read-write", default_value=False)
active_trigger = Property(str, flags="read-write", default_value="")
def __init__(self, **kwargs):
super().__init__(
name="launcher-window",
title="modus-launcher",
layer="top",
anchor="center",
exclusivity="none",
keyboard_mode="exclusive",
visible=False, # Start hidden until explicitly shown
**kwargs,
)
self._initializing = True
self._auto_adding_space = False
self._processing_backspace = False
self.plugin_manager = PluginManager()
self.trigger_config = TriggerConfig()
self.results: List[Result] = []
self.selected_index = 0
self.max_results = 5 # Show 4 applications by default instead of list
# Trigger system
self.triggered_plugin = None # Currently active triggered plugin
self.active_trigger = "" # Currently active trigger keyword
self.query = "" # Current search query
self.visible = False # Launcher visibility state
self.opened_with_trigger = False # Whether launcher was opened with a trigger
# Focus management
self.focus_mode = "search" # "search", "results"
# Mouse activity tracking
self._mouse_active = False
self._last_mouse_activity = 0
self._mouse_activity_timeout = 1000 # 1 second timeout
self._keyboard_used_recently = False
self._last_keyboard_activity = 0
self._keyboard_activity_timeout = 2000 # 2 second timeout
self._launcher_just_opened = False
self._mouse_interaction_delay = 500 # 500ms delay after opening
# Setup UI
main_box = Box(
name="launcher",
orientation="v",
spacing=0,
h_align="center",
v_align="center",
)
self.add(main_box)
# Search entry with Spotlight-style large text
self.search_entry = Entry(
name="launcher-search",
placeholder="Spotlight Search",
h_expand=True,
h_align="fill",
notify_text=lambda entry, *_: self._on_search_changed(entry),
)
self.search_entry.connect("changed", self._on_search_changed)
self.search_entry.connect("activate", self._on_entry_activate)
self.header_box = Box(
name="header_box",
spacing=10,
orientation="h",
children=[
self.search_entry,
],
)
main_box.add(self.header_box)
self.results_scroll = ScrolledWindow(
name="launcher-results-scroll",
h_scrollbar_policy="never",
min_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT),
max_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT),
propagate_width=False,
propagate_height=False,
)
self.results_box = Box(
name="launcher-results",
orientation="v",
spacing=0,
)
self.results_scroll.add(self.results_box)
main_box.add(self.results_scroll)
# Keep the results container visible initially
self.connect("key-press-event", self._on_key_press)
# Connect mouse events to track activity
self.results_scroll.connect("button-press-event", self._on_mouse_activity)
self.results_scroll.connect("motion-notify-event", self._on_mouse_activity)
self.results_scroll.connect("scroll-event", self._on_mouse_activity)
# Also track mouse activity on the main window for better coverage
self.connect("button-press-event", self._on_mouse_activity)
self.connect("motion-notify-event", self._on_mouse_activity)
self.hide()
# Mark initialization as complete
self._initializing = False
# Hide trigger suggestions at startup
self._clear_results()
def show_launcher(self, trigger_keyword: str = None, external: bool = False):
"""Show the launcher and focus the search entry, or execute command externally.
Args:
trigger_keyword: Optional trigger keyword to activate immediately (e.g., "google", "calc", "app")
external: If True, execute the command without showing the launcher UI
"""
# Reset mouse activity tracking when launcher is shown
self._mouse_active = False
self._last_mouse_activity = 0
self._keyboard_used_recently = False
self._last_keyboard_activity = 0
self._launcher_just_opened = True
# Schedule to allow mouse interactions after a delay
def enable_mouse_interactions():
self._launcher_just_opened = False
return False
GLib.timeout_add(self._mouse_interaction_delay, enable_mouse_interactions)
if external and trigger_keyword:
# Execute command externally without showing launcher
return self._execute_external_command(trigger_keyword)
self.show_all()
if trigger_keyword:
# Set flag to track that launcher was opened with a trigger keyword
self.opened_with_trigger = True
# Set the trigger keyword with a space and activate trigger mode
trigger_text = f"{trigger_keyword} "
self.search_entry.set_text(trigger_text)
# Detect and activate the trigger
triggered_plugin, detected_trigger = self._detect_trigger(trigger_text)
if triggered_plugin:
self.triggered_plugin = triggered_plugin
self.active_trigger = detected_trigger
# Query the plugin with empty string to show default options
try:
results = triggered_plugin.query("")
self.results = results
self.selected_index = 0
self._update_results_display()
except Exception as e:
print(
f"Error querying triggered plugin {triggered_plugin.name}: {e}"
)
self._clear_results()
else:
# Trigger not found, clear and show error or fallback
self.search_entry.set_text("")
self._clear_results()
else:
# Normal launcher opening - show applications
self.opened_with_trigger = False
self.search_entry.set_text("")
# Trigger initial search to show applications immediately
self._perform_search("")
# Reset focus mode to search
self.focus_mode = "search"
# Focus search entry without selecting text
if trigger_keyword:
# For trigger keywords, we want the cursor at the end
self.search_entry.grab_focus()
def position_cursor():
if hasattr(self.search_entry, "set_position"):
self.search_entry.set_position(-1) # Move caret to end
return False # Only run once
GLib.idle_add(position_cursor)
else:
# For normal opening, use our method that prevents text selection
self._focus_search_entry_without_selection()
self.visible = True
def _position_cursor_at_end(self, text_length: Optional[int] = None) -> None:
"""Position cursor at the end of search entry text."""
if text_length is None:
text_length = len(self.search_entry.get_text())
def position_cursor():
if hasattr(self.search_entry, "set_position"):
self.search_entry.set_position(-1) # Move caret to end
if hasattr(self.search_entry, "select_region"):
self.search_entry.select_region(
text_length, text_length
) # No selection
return False # Only run once
GLib.idle_add(position_cursor)
def _add_space_to_trigger(self, trigger_word: str) -> None:
"""Add space after trigger keyword and position cursor."""
trigger_text_with_space = f"{trigger_word} "
# Temporarily disable search change handling to prevent recursion
self._auto_adding_space = True
self.search_entry.set_text(trigger_text_with_space)
# Position cursor at the end
def position_cursor():
if hasattr(self.search_entry, "set_position"):
self.search_entry.set_position(-1) # Move caret to end
if hasattr(self.search_entry, "select_region"):
self.search_entry.select_region(
len(trigger_text_with_space), len(trigger_text_with_space)
) # No selection
self._auto_adding_space = False
return False # Only run once
GLib.idle_add(position_cursor)
# Update query
self.query = trigger_text_with_space
return trigger_text_with_space
def close_launcher(self):
"""Hide the launcher and clear search."""
self.hide()
self.search_entry.set_text("")
self._clear_results()
self.triggered_plugin = None
self.active_trigger = ""
self.visible = False
self.opened_with_trigger = False
def _on_search_changed(self, entry):
"""Handle search text changes."""
# Skip if still initializing
if getattr(self, "_initializing", True):
return
# Skip if we're automatically adding a space to prevent recursion
if getattr(self, "_auto_adding_space", False):
return
# Skip if we're processing a backspace to prevent interference
if getattr(self, "_processing_backspace", False):
return
# Reset focus to search when user types
if self.focus_mode != "search":
self.focus_mode = "search"
query = entry.get_text().strip()
self.query = query
# If query is exactly ':', show all triggers
if query == ":":
self._show_available_triggers()
# If query matches a trigger exactly (with or without space), handle trigger activation
elif any(
query == trig or query == f"{trig} "
for trig in [
t.strip()
for p in self.plugin_manager.get_active_plugins()
for t in p.get_triggers()
]
):
# Check if we need to add space immediately for exact trigger matches
if not query.endswith(" "):
# This is an exact trigger match without space - add space immediately
trigger_text_with_space = self._add_space_to_trigger(query)
GLib.timeout_add(
TRIGGER_SEARCH_DEBOUNCE_MS,
self._perform_search,
trigger_text_with_space,
)
else:
# Already has space, proceed with normal search
GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query)
elif query:
# Debounce search to avoid too many queries
GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query)
else:
# Show applications when query is empty
self._perform_search("")
def _perform_search(self, query: str) -> bool:
"""Perform search across all plugins."""
# Only search if query hasn't changed
if query != self.query:
return False
if not query:
# Empty query - show popular applications
self.triggered_plugin = None
self.active_trigger = ""
# Get applications plugin and show popular apps
applications_plugin = self._get_applications_plugin()
if applications_plugin:
try:
# Query with empty string to get popular/frequently used applications
all_results = applications_plugin.query("")
# Limit to max_results for empty query
self.results = all_results[: self.max_results]
self.selected_index = 0
self._update_results_display()
except Exception as e:
print(f"Error getting applications: {e}")
self._clear_results()
else:
self._clear_results()
return False
# Check if we're already in trigger mode
if self.triggered_plugin and self.active_trigger:
# We're in trigger mode - search within the triggered plugin
try:
# Extract the search query after the trigger
remaining_query = self._extract_query_after_trigger(
query, self.active_trigger
)
all_results = self.triggered_plugin.query(remaining_query)
except Exception as e:
print(f"Error in triggered plugin {self.triggered_plugin.name}: {e}")
all_results = []
else:
# Check for trigger activation
triggered_plugin, trigger = self._detect_trigger(query)
if triggered_plugin:
# New trigger detected - check if we need to add space automatically
trigger_word = trigger.strip()
current_text = self.search_entry.get_text()
# If the current text is exactly the trigger word (no space), add space automatically
if (
current_text.strip() == trigger_word
and not current_text.endswith(" ")
and not getattr(self, "_auto_adding_space", False)
):
query = self._add_space_to_trigger(trigger_word)
# Enter trigger mode
self.triggered_plugin = triggered_plugin
self.active_trigger = trigger
# Extract search query after trigger
remaining_query = self._extract_query_after_trigger(query, trigger)
# Always call the plugin's query method, even with empty remaining query
# This allows plugins to show default options when just the trigger is typed
try:
all_results = triggered_plugin.query(remaining_query)
except Exception as e:
print(f"Error in triggered plugin {triggered_plugin.name}: {e}")
all_results = []
else:
# No trigger detected - search applications and show trigger suggestions
self.triggered_plugin = None
self.active_trigger = ""
all_results = []
# Search applications directly without trigger
applications_plugin = self._get_applications_plugin()
if applications_plugin:
try:
app_results = applications_plugin.query(query)
all_results.extend(app_results)
except Exception as e:
print(f"Error searching applications: {e}")
# Also show trigger suggestions if query matches trigger prefixes
trigger_suggestions = self._get_trigger_suggestions(query)
all_results.extend(trigger_suggestions)
# Sort results by relevance score
all_results.sort(key=lambda r: r.relevance, reverse=True)
# Check if any results have bypass_max_results flag
has_bypass = any(
hasattr(r, "data") and r.data and r.data.get("bypass_max_results")
for r in all_results
)
# Don't limit results for triggered plugin queries, only for global searches and trigger suggestions
if self.triggered_plugin and self.active_trigger:
# In trigger mode - show all results from the triggered plugin
self.results = all_results
elif not has_bypass:
# Global search - check if it's an application search
applications_plugin = self._get_applications_plugin()
is_application_search = applications_plugin and any(
hasattr(r, "plugin_name") and r.plugin_name == "Applications"
for r in all_results
)
if is_application_search:
# Don't limit application search results
self.results = all_results
else:
# Apply max_results limit for other global searches and trigger suggestions
self.results = all_results[: self.max_results]
else:
# Has bypass flag - show all results
self.results = all_results
self.selected_index = 0
# Update UI
self._update_results_display()
return False # Don't repeat timeout
def _extract_query_after_trigger(self, query: str, trigger: str) -> str:
"""
Extract the search query after removing the trigger.
Args:
query: The full query string
trigger: The trigger keyword
Returns:
The remaining query after the trigger
"""
if not query or not trigger:
return ""
query_lower = query.lower()
trigger_lower = trigger.lower()
# Handle trigger with space (e.g., "app ")
if trigger_lower.endswith(" ") and query_lower.startswith(trigger_lower):
return query[len(trigger) :].strip()
# Handle trigger without space (e.g., "app")
trigger_word = trigger.strip().lower()
if query_lower.startswith(trigger_word):
# Check if it's followed by space or end of string
if len(query) == len(trigger_word):
return ""
elif len(query) > len(trigger_word) and query[len(trigger_word)] == " ":
return query[len(trigger_word) + 1 :].strip()
elif len(query) > len(trigger_word):
# No space after trigger word, extract rest
return query[len(trigger_word) :].strip()
return ""
def _show_available_triggers(self):
"""Show available triggers when launcher is first opened."""
trigger_suggestions = self._get_trigger_suggestions("")
self.results = trigger_suggestions # Show all available triggers without limit
self.selected_index = 0
self._update_results_display()
def _detect_trigger(self, query: str) -> Tuple[Optional[object], str]:
"""
Detect if query starts with a trigger keyword.
Args:
query: The search query
Returns:
Tuple of (plugin, trigger) if triggered, (None, "") otherwise
"""
if not query.strip():
return None, ""
# Check all plugins for triggers
for plugin in self.plugin_manager.get_active_plugins():
trigger = plugin.get_active_trigger(query)
if trigger:
return plugin, trigger
return None, ""
def _get_applications_plugin(self):
"""Get the applications plugin instance."""
for plugin in self.plugin_manager.get_active_plugins():
if (
hasattr(plugin, "display_name")
and plugin.display_name == "Applications"
):
return plugin
return None
def _get_trigger_suggestions(self, query: str) -> List[Result]:
"""
Get trigger suggestions based on the current query.
Args:
query: The search query
Returns:
List of Result objects showing available triggers
"""
suggestions = []
query_lower = query.lower().strip()
# Get all available triggers from plugins
all_triggers = {}
for plugin in self.plugin_manager.get_active_plugins():
triggers = plugin.get_triggers()
for trigger in triggers:
trigger_clean = trigger.strip()
if trigger_clean not in all_triggers:
all_triggers[trigger_clean] = {
"plugin": plugin,
"trigger": trigger,
"examples": [],
}
# Get max examples to show from configuration
max_examples = self.trigger_config.settings.get("max_examples_shown", 2)
# Show trigger suggestions based on query
if query_lower:
# Show triggers that match the query
for trigger_clean, _ in all_triggers.items():
if trigger_clean.lower().startswith(query_lower):
result = self._create_trigger_result(trigger_clean, max_examples)
suggestions.append(result)
else:
# Empty query - show all available triggers
for trigger_clean, _ in all_triggers.items():
result = self._create_trigger_result(trigger_clean, max_examples)
suggestions.append(result)
return suggestions # Return all trigger suggestions without limit
def _create_trigger_result(self, trigger_clean: str, max_examples: int) -> Result:
"""Create a Result object for a trigger suggestion."""
examples = self.trigger_config.get_trigger_examples(trigger_clean)
icon_name = self.trigger_config.get_trigger_icon(trigger_clean)
description = self.trigger_config.get_trigger_description(trigger_clean)
return Result(
title=f"{trigger_clean}",
subtitle=f"{description} - {', '.join(examples[:max_examples])}",
icon_name=icon_name,
action=lambda t=trigger_clean: self._activate_trigger(t),
# Shorter triggers get higher relevance
relevance=100 - len(trigger_clean),
data={"type": "trigger_suggestion", "trigger": trigger_clean},
)
def _activate_trigger(self, trigger: str):
"""
Activate a trigger by setting it in the search entry.
Args:
trigger: The trigger keyword to activate
"""
# Set the trigger text in the search entry
trigger_text = f"{trigger} "
self.search_entry.set_text(trigger_text)
self.search_entry.grab_focus()
def clear_selection():
if hasattr(self.search_entry, "set_position"):
self.search_entry.set_position(-1) # Move caret to end
if hasattr(self.search_entry, "select_region"):
self.search_entry.select_region(
len(trigger_text), len(trigger_text)
) # No selection
return False # Only run once
GLib.idle_add(clear_selection)
# Manually set the trigger mode to avoid search processing issues
triggered_plugin, detected_trigger = self._detect_trigger(trigger_text)
if triggered_plugin:
self.triggered_plugin = triggered_plugin
self.active_trigger = detected_trigger
# Clear results and show trigger ready state
self.results = []
self.selected_index = 0
self._update_results_display()
# Focus back to the search entry for immediate typing
self.search_entry.grab_focus()
# Don't hide the launcher - user should be able to continue typing
def _execute_external_command(self, command_string: str):
"""Execute a command externally without showing the launcher UI.
Args:
command_string: Full command string (e.g., "wall random", "calc 2+2")
Returns:
Result of the command execution or None if failed
"""
try:
# Parse the command to extract trigger and query
parts = command_string.strip().split(" ", 1)
if not parts:
return None
trigger_part = parts[0]
query_part = parts[1] if len(parts) > 1 else ""
# Find the plugin that handles this trigger
triggered_plugin = None
for plugin in self.plugin_manager.get_active_plugins():
trigger = plugin.get_active_trigger(f"{trigger_part} ")
if trigger:
triggered_plugin = plugin
break
if not triggered_plugin:
print(f"No plugin found for trigger: {trigger_part}")
return None
# Query the plugin with the remaining query
try:
results = triggered_plugin.query(query_part)
if not results:
print(f"No results found for query: {query_part}")
return None
# Find the first result that matches the query exactly or has highest relevance
best_result = None
for result in results:
# For exact matches like "random", execute immediately
if (
hasattr(result, "data")
and result.data
and result.data.get("action") == query_part.strip()
):
best_result = result
break
# For partial matches, take the first high-relevance result
elif not best_result and result.relevance >= 0.9:
best_result = result
# If no exact match, take the first result
if not best_result and results:
best_result = results[0]
if best_result:
# Execute the result action
try:
result_value = best_result.activate()
print(f"External command executed: {command_string}")
return result_value
except Exception as e:
print(f"Error executing result action: {e}")
return None
else:
print(f"No suitable result found for: {command_string}")
return None
except Exception as e:
print(f"Error querying plugin {triggered_plugin.name}: {e}")
return None
except Exception as e:
print(f"Error executing external command '{command_string}': {e}")
return None
def _update_results_display(self):
"""Update the results display."""
# Skip if still initializing or results_box not ready
if getattr(self, "_initializing", True) or not hasattr(self, "results_box"):
return
# Update input field with trigger indication (Spotlight-style)
self._update_input_action_text()
# Clear existing results
for child in self.results_box.get_children():
self.results_box.remove(child)
# Add new results
for i, result in enumerate(self.results):
# Check if this result has a custom widget
if result.custom_widget:
# Ensure the widget is not already parented
parent = result.custom_widget.get_parent()
if parent:
parent.remove(result.custom_widget)
result.custom_widget.show_all() # Ensure widget is visible
self.results_box.add(result.custom_widget)
else:
# Create normal result item
result_item = ResultItem(
result=result, selected=(i == self.selected_index), index=i
)
result_item.clicked.connect(
lambda _, idx=i: self._on_result_clicked(result_item, idx)
)
result_item.hovered.connect(
lambda _, idx=i: self._on_result_hovered(idx)
)
self.results_box.add(result_item)
self.results_box.show_all()
self.results_scroll.show()
def _update_input_action_text(self):
"""Update the input field with action text (Spotlight-style)."""
# Check if search_entry is initialized
if not hasattr(self, "search_entry") or self.search_entry is None:
return
# Get the current input text
current_text = self.search_entry.get_text()
# Spotlight-style placeholder text
if self.triggered_plugin:
if self.results:
# Show more minimal placeholder for triggered mode
self.search_entry.set_placeholder_text(
f"Search {self.active_trigger.strip()}..."
)
else:
# In trigger mode but no results yet
if current_text == self.active_trigger.strip():
# Just the trigger keyword
self.search_entry.set_placeholder_text(
f"Search {self.active_trigger.strip()}..."
)
else:
# Searching within trigger
self.search_entry.set_placeholder_text(
f"Searching {self.active_trigger.strip()}..."
)
else:
# Not in trigger mode - show Spotlight-style help
if current_text == ":":
self.search_entry.set_placeholder_text("Available search triggers")
else:
self.search_entry.set_placeholder_text("Spotlight Search")
def _clear_results(self):
"""Clear all results."""
self.results = []
self.selected_index = 0
for child in self.results_box.get_children():
self.results_box.remove(child)
# Keep the results scroll visible even when empty
def _handle_escape_key(self) -> bool:
"""Handle escape key press."""
# First check if there's a password entry widget that should handle Escape
password_entry_widget = self._find_password_entry_widget()
if password_entry_widget:
# Cancel the password entry
password_entry_widget.cancel_password_entry()
return True
elif self.opened_with_trigger:
# If launcher was opened with a trigger keyword, close directly
self.close_launcher()
return True
else:
# Hide launcher
self.close_launcher()
return True
def _handle_backspace_key(self) -> bool:
"""Handle backspace key press in trigger mode."""
if self.triggered_plugin:
trigger_text = self.active_trigger.strip()
current_text = self.search_entry.get_text()
# Set flag to prevent search change handler from interfering
self._processing_backspace = True
# Allow normal backspace behavior first, then check if we need to exit trigger mode
# Don't intercept the backspace - let GTK handle it normally
# Schedule a check after the backspace is processed
def check_trigger_after_backspace():
try:
# Get the text after backspace has been processed
new_text = self.search_entry.get_text()
# Check if we should exit trigger mode
# We need to check if the text still matches the trigger pattern
should_exit_trigger = False
# If the active trigger ends with a space (like "calc "),
# we should exit if the text doesn't contain that space anymore
if self.active_trigger.endswith(" "):
# For triggers like "calc ", exit if text is just "calc" or doesn't start with "calc "
trigger_with_space = self.active_trigger.lower()
if not new_text.lower().startswith(trigger_with_space):
should_exit_trigger = True
else:
# For triggers without space, exit if text doesn't start with trigger
if not new_text.lower().startswith(trigger_text.lower()):
should_exit_trigger = True
if should_exit_trigger:
self.triggered_plugin = None
self.active_trigger = ""
self._clear_results()
# Don't clear the text - let the user's edit stand
# If we're still in trigger mode but the text changed, update the search
elif self.triggered_plugin and new_text != current_text:
# Trigger a search with the new text
self.query = new_text
GLib.timeout_add(50, self._perform_search, new_text)
finally:
# Clear the backspace processing flag
self._processing_backspace = False
return False # Don't repeat
# Use idle_add to check after the backspace is processed
GLib.idle_add(check_trigger_after_backspace)
# Allow the normal backspace to proceed
return False
# Let normal backspace behavior continue for other cases
return False
def _on_key_press(self, _widget, event):
"""Handle key press events."""
# Track keyboard activity
import time
current_time = int(time.time() * 1000)
self._last_keyboard_activity = current_time
self._keyboard_used_recently = True
keyval = event.keyval
# Escape - handle password entry, exit trigger mode, or hide launcher
if keyval == Gdk.KEY_Escape:
return self._handle_escape_key()
# Backspace - handle trigger mode backspace behavior
if keyval == Gdk.KEY_BackSpace:
return self._handle_backspace_key()
# Up/Down - navigate results (alternative to Tab)
if keyval == Gdk.KEY_Up:
if self.focus_mode == "results" and self.results:
if self.selected_index > 0:
# Move to previous result
self.selected_index -= 1
self._update_selection()
else:
# At first result, go back to search entry
self.focus_mode = "search"
self._focus_search_entry_without_selection()
elif self.results:
# If not in results mode but have results, enter results mode at last item
self.focus_mode = "results"
self.selected_index = len(self.results) - 1
self._update_selection()
return True
if keyval == Gdk.KEY_Down:
if self.focus_mode == "results" and self.results:
if self.selected_index < len(self.results) - 1:
# Move to next result
self.selected_index += 1
self._update_selection()
else:
# At last result, wrap around to first result
self.selected_index = 0
self._update_selection()
elif self.results:
# If not in results mode but have results, enter results mode at first item
self.focus_mode = "results"
self.selected_index = 0
self._update_selection()
return True
# Enter - activate selected result
if keyval == Gdk.KEY_Return:
# Check for Shift+Enter for alternative actions
if event.state & Gdk.ModifierType.SHIFT_MASK:
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
if result.data:
# Check for generic alternative action first
if result.data.get("alt_action"):
result.data["alt_action"]()
return True
# Fallback to pin_action for backward compatibility
elif result.data.get("pin_action"):
result.data["pin_action"]()
return True
# Check if the selected result has a custom widget with Entry fields
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
if result.custom_widget:
# Check if the custom widget contains Entry widgets that should handle Enter
if self._custom_widget_has_entry(result.custom_widget):
# Let the custom widget handle the Enter key
# Find the focused Entry widget and trigger its activate signal
focused_entry = self._find_focused_entry_in_widget(
result.custom_widget
)
if focused_entry:
focused_entry.emit("activate")
return True
# Normal Enter behavior
self._activate_selected()
return True
# Tab - cycle through focus areas and results
if keyval == Gdk.KEY_Tab:
if event.state & Gdk.ModifierType.SHIFT_MASK:
# Shift+Tab - reverse direction
if self.focus_mode == "results":
# Navigate through results in reverse
self._navigate_results_backward()
else:
self._cycle_focus_backward()
else:
# Tab - forward direction
if self.focus_mode == "results":
# Navigate through results forward
self._navigate_results_forward()
else:
self._cycle_focus_forward()
return True
# Page Up/Page Down - navigate results faster
if keyval == Gdk.KEY_Page_Up:
if self.results:
self.selected_index = max(0, self.selected_index - PAGE_NAVIGATION_STEP)
self._update_selection()
return True
if keyval == Gdk.KEY_Page_Down:
if self.results:
self.selected_index = min(
len(self.results) - 1, self.selected_index + PAGE_NAVIGATION_STEP
)
self._update_selection()
return True
# Home/End - go to first/last result
if keyval == Gdk.KEY_Home:
if self.results:
self.selected_index = 0
self._update_selection()
return True
if keyval == Gdk.KEY_End:
if self.results:
self.selected_index = len(self.results) - 1
self._update_selection()
return True
# Forward other keys to custom widgets if they can handle them
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
if result.custom_widget and hasattr(result.custom_widget, "on_key_press"):
# Try to forward the key event to the custom widget
if result.custom_widget.on_key_press(result.custom_widget, event):
return True
return False
def _custom_widget_has_entry(self, widget):
"""Check if a custom widget contains Entry widgets."""
if isinstance(widget, Entry):
return True
# Check children recursively
if hasattr(widget, "get_children"):
for child in widget.get_children():
if self._custom_widget_has_entry(child):
return True
return False
def _find_focused_entry_in_widget(self, widget):
"""Find the focused Entry widget within a custom widget."""
if isinstance(widget, Entry):
# Try multiple ways to check if this Entry is focused
try:
if widget.has_focus() or widget.is_focus():
return widget
# Also check if this is the only Entry in the widget (likely to be the target)
return widget
except:
# If focus checking fails, assume this Entry should handle the event
return widget
# Check children recursively
if hasattr(widget, "get_children"):
for child in widget.get_children():
focused_entry = self._find_focused_entry_in_widget(child)
if focused_entry:
return focused_entry
return None
def _find_password_entry_widget(self):
"""Find a NetworkPasswordEntry widget in the current results."""
for result in self.results:
if result.custom_widget:
# Check if this is a NetworkPasswordEntry widget
if (
hasattr(result.custom_widget, "__class__")
and result.custom_widget.__class__.__name__
== "NetworkPasswordEntry"
):
return result.custom_widget
# Also check if it has the cancel_password_entry method (duck typing)
elif hasattr(result.custom_widget, "cancel_password_entry"):
return result.custom_widget
return None
def _on_entry_activate(self, _entry):
"""Handle entry activation (Enter key)."""
self._activate_selected()
def _on_result_clicked(self, _result_item, index):
"""Handle result item click."""
# Only allow clicks when mouse is active or keyboard hasn't been used recently
if self._should_allow_mouse_interaction():
self.selected_index = index
self._activate_selected()
def _on_result_hovered(self, index):
"""Handle result item hover."""
# Only allow hover selection when mouse is active and keyboard hasn't been used recently
if self._should_allow_mouse_interaction():
if 0 <= index < len(self.results):
self.selected_index = index
self.focus_mode = "results" # Switch to results focus mode
self._update_selection_visual_only()
def _on_mouse_activity(self, widget, event):
"""Track mouse activity to determine when mouse interactions should be enabled."""
import time
current_time = int(time.time() * 1000)
self._last_mouse_activity = current_time
self._mouse_active = True
# Schedule a check to disable mouse activity after timeout
def check_mouse_timeout():
if current_time - self._last_mouse_activity >= self._mouse_activity_timeout:
self._mouse_active = False
return False
GLib.timeout_add(self._mouse_activity_timeout, check_mouse_timeout)
return False
def _should_allow_mouse_interaction(self):
"""Determine if mouse interactions should be allowed."""
import time
current_time = int(time.time() * 1000)
# Don't allow mouse interactions immediately after launcher opens
if self._launcher_just_opened:
return False
# Allow mouse interaction if:
# 1. Mouse is currently active (recent activity)
# 2. OR keyboard hasn't been used recently (2 seconds)
mouse_recently_active = (
current_time - self._last_mouse_activity
) < self._mouse_activity_timeout
keyboard_not_recently_used = (
current_time - self._last_keyboard_activity
) > self._keyboard_activity_timeout
return mouse_recently_active or keyboard_not_recently_used
def _is_mouse_over_results(self):
"""Check if mouse is over the results area."""
# Simple check - if we have results visible, assume user might be interacting
return len(self.results) > 0 and self.results_scroll.get_visible()
def _update_selection(self):
"""Update the visual selection of results with scrolling (for keyboard navigation)."""
children = self.results_box.get_children()
selected_widget = None
for i, child in enumerate(children):
if isinstance(child, ResultItem):
is_selected = i == self.selected_index
child.set_selected(is_selected)
if is_selected:
selected_widget = child
# For custom widgets, we don't need to handle selection visually
# since they manage their own interaction
# Focus custom widgets when selected for keyboard interaction
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
if result.custom_widget and result.custom_widget.get_can_focus():
# Give focus to the custom widget for keyboard interaction
result.custom_widget.grab_focus()
# Scroll to make the selected item visible
if selected_widget and self.results_scroll.get_visible():
# Use immediate scrolling for better responsiveness
self._scroll_to_widget(selected_widget)
# Also schedule a more accurate scroll after layout is complete
GLib.idle_add(self._ensure_selected_visible)
def _update_selection_visual_only(self):
"""Update the visual selection of results without scrolling (for mouse hover)."""
children = self.results_box.get_children()
for i, child in enumerate(children):
if isinstance(child, ResultItem):
is_selected = i == self.selected_index
child.set_selected(is_selected)
# For custom widgets, we don't need to handle selection visually
# since they manage their own interaction
# Focus custom widgets when selected for keyboard interaction
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
if result.custom_widget and result.custom_widget.get_can_focus():
# Give focus to the custom widget for keyboard interaction
result.custom_widget.grab_focus()
def _scroll_to_widget(self, widget):
"""Scroll the results container to make the widget visible."""
if not widget or not self.results_scroll.get_visible():
return
# Debug output (can be removed in production)
# print(f"Scrolling to selected index: {self.selected_index}")
# Get the scrolled window's vertical adjustment
vadjustment = self.results_scroll.get_vadjustment()
if not vadjustment:
return
# Use a simpler approach: scroll to the selected item index
if self.results and 0 <= self.selected_index < len(self.results):
# Get all children to work with actual widgets
children = self.results_box.get_children()
if not children or self.selected_index >= len(children):
return
# Get the selected child widget
selected_child = children[self.selected_index]
# Try to get actual allocation, fallback to estimation
try:
allocation = selected_child.get_allocation()
item_height = allocation.height if allocation.height > 0 else 68
item_y = allocation.y
except:
# Fallback to estimation
item_height = DEFAULT_ITEM_HEIGHT
item_y = self.selected_index * item_height
# Get current scroll info
current_scroll = vadjustment.get_value()
page_size = vadjustment.get_page_size()
max_scroll = vadjustment.get_upper() - page_size
# Calculate visible area
visible_top = current_scroll
visible_bottom = current_scroll + page_size
# Check if selected item is visible
item_top = item_y
item_bottom = item_y + item_height
# Add some padding for better visibility
# Scroll if needed
if item_top < visible_top + SCROLL_PADDING:
# Item is above visible area - scroll up
new_scroll = max(0, item_top - SCROLL_PADDING)
vadjustment.set_value(new_scroll)
elif item_bottom > visible_bottom - SCROLL_PADDING:
# Item is below visible area - scroll down
new_scroll = min(max_scroll, item_bottom - page_size + SCROLL_PADDING)
vadjustment.set_value(new_scroll)
def _ensure_selected_visible(self):
"""Alternative method to ensure selected item is visible using GTK methods."""
if (
not self.results
or self.selected_index < 0
or self.selected_index >= len(self.results)
):
return False
children = self.results_box.get_children()
if self.selected_index < len(children):
selected_child = children[self.selected_index]
# Try to use widget's allocation for more accurate scrolling
try:
allocation = selected_child.get_allocation()
if allocation.height > 0:
vadjustment = self.results_scroll.get_vadjustment()
if vadjustment:
# Calculate the position to center the selected item
page_size = vadjustment.get_page_size()
target_pos = (
allocation.y - (page_size / 2) + (allocation.height / 2)
)
target_pos = max(
0, min(target_pos, vadjustment.get_upper() - page_size)
)
vadjustment.set_value(target_pos)
except Exception as e:
print(f"Error in _ensure_selected_visible: {e}")
return False # Don't repeat the idle callback
def _cycle_focus_forward(self):
"""Cycle focus forward: search -> results"""
if self.focus_mode == "search":
if self.results:
self.focus_mode = "results"
self._update_selection()
def _cycle_focus_backward(self):
"""Cycle focus backward: results -> search"""
if self.focus_mode == "results":
self.focus_mode = "search"
self._focus_search_entry_without_selection()
def _focus_search_entry_without_selection(self):
"""Focus search entry and position cursor at end without selecting text."""
# First grab focus
self.search_entry.grab_focus()
# Use multiple approaches to prevent text selection
def clear_selection():
try:
text_length = len(self.search_entry.get_text())
# Method 1: Set position and clear selection
if hasattr(self.search_entry, "set_position"):
self.search_entry.set_position(text_length)
if hasattr(self.search_entry, "select_region"):
self.search_entry.select_region(text_length, text_length)
# Method 2: Try to access underlying GTK widget
try:
# For fabric Entry widgets, try to get the actual GTK Entry
if hasattr(self.search_entry, "_entry"):
gtk_entry = self.search_entry._entry
elif (
hasattr(self.search_entry, "get_children")
and self.search_entry.get_children()
):
gtk_entry = self.search_entry.get_children()[0]
else:
gtk_entry = self.search_entry
if hasattr(gtk_entry, "set_position"):
gtk_entry.set_position(text_length)
if hasattr(gtk_entry, "select_region"):
gtk_entry.select_region(text_length, text_length)
except Exception:
pass
except Exception as e:
print(f"Could not clear selection: {e}")
return False
# Schedule clearing selection after focus is established
GLib.idle_add(clear_selection)
# Also try with a small delay as backup
GLib.timeout_add(CURSOR_POSITION_DELAY_MS, clear_selection)
def _navigate_results_forward(self):
"""Navigate to next result or wrap around to first result."""
if self.results and self.selected_index < len(self.results) - 1:
# Move to next result
self.selected_index += 1
self._update_selection()
else:
# At last result, wrap around to first result
self.selected_index = 0
self._update_selection()
def _navigate_results_backward(self):
"""Navigate to previous result or exit results mode."""
if self.results and self.selected_index > 0:
# Move to previous result
self.selected_index -= 1
self._update_selection()
else:
# Exit results mode and go to search
self.focus_mode = "search"
self._focus_search_entry_without_selection()
def _activate_selected(self):
"""Activate the currently selected result."""
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
try:
# Check if this result has a custom widget
if result.custom_widget:
# For custom widgets, we don't activate them since they're already displayed
# The widget handles its own interactions
return
# Check if this is a trigger suggestion or should keep launcher open
is_trigger_suggestion = (
result.data and result.data.get("type") == "trigger_suggestion"
)
keep_launcher_open = result.data and result.data.get(
"keep_launcher_open", False
)
# Activate the result
result.activate()
# Only hide launcher if it's not a trigger suggestion and doesn't have keep_launcher_open flag
if not is_trigger_suggestion and not keep_launcher_open:
self.close_launcher()
# For trigger suggestions and keep_launcher_open actions, the launcher stays open
except Exception as e:
print(f"Error activating result: {e}")