diff --git a/src/utils/debounce.py b/src/utils/debounce.py index 8be6b3b..98b8055 100644 --- a/src/utils/debounce.py +++ b/src/utils/debounce.py @@ -1,9 +1,36 @@ import functools import typing as t +from collections.abc import Callable from fabric.utils import GLib +def debounce(ms: int): + """Debounce a class method using GLib timers. + + Stores timer state on the instance (`self`), so this only works + as a method decorator. + """ + + def decorator(func: Callable): + timer_id_attr = f"_debounce_timer_{func.__name__}" + + def wrapper(self, *args, **kwargs): + if existing_timer := getattr(self, timer_id_attr, None): + GLib.source_remove(existing_timer) + + def timeout_cb(): + setattr(self, timer_id_attr, 0) + func(self, *args, **kwargs) + return False + + setattr(self, timer_id_attr, GLib.timeout_add(ms, timeout_cb)) + + return wrapper + + return decorator + + def sync_debounce( delay: int, min_n_times: int = 0, immediate: bool = False ) -> t.Callable[..., t.Any]: diff --git a/src/utils/functions.py b/src/utils/functions.py index 5176331..dcc8ae6 100644 --- a/src/utils/functions.py +++ b/src/utils/functions.py @@ -1,13 +1,65 @@ import ctypes import html +import json +import os import subprocess import threading -from typing import Dict, NamedTuple, Optional +from collections.abc import Callable +from functools import reduce +from typing import Dict, NamedTuple, Optional, TypeVar, cast from fabric.utils import ( + Gdk, + GdkPixbuf, + Gtk, + cairo, exec_shell_command_async, logger, ) +from fabric.widgets.box import Box + +T = TypeVar("T") + + +class Rectangle(NamedTuple): + x: float + y: float + width: float + height: float + + +def get_children_height_limit( + viewport: Box, + max_n_children: int, + transform_func: Callable[[Gtk.Widget], cairo.RectangleInt] | None = None, +) -> int: + spacing: int = viewport.get_spacing() + + children = viewport.children + children_len = len(viewport.children) + + if children_len < 1: + return 0 + + if children_len > max_n_children: + children_len = max_n_children + + # calculate the new height + # ( ) + ( ) + return (spacing * (children_len - 1)) + reduce( + lambda x, y: x + y, + ( + ( + transform_func(children[i]) + if transform_func + else cast( + "cairo.RectangleInt", + children[i].get_preferred_size().minimum_size, # type: ignore + ) + ).height # type: ignore + for i in range(children_len) + ), + ) def set_process_name(name: str): @@ -56,6 +108,52 @@ def thread(target, *args, **kwargs) -> threading.Thread: return th +def copy_text(text: str) -> bool: + try: + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(text, -1) + clipboard.store() + return True + except Exception: + return False + + +def copy_image(image_path: str) -> bool: + try: + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + image = GdkPixbuf.Pixbuf.new_from_file(image_path) + clipboard.set_image(image) + clipboard.store() + return True + except Exception: + return False + + +def read_json_file(file_path: str) -> dict | None: + if not os.path.exists(file_path): + logger.error(f"JSON file {file_path} does not exist.") + return None + + with open(file_path) as file: + try: + return json.load(file) + except json.JSONDecodeError as e: + logger.error(f"Failed to read JSON file {file_path}: {e}") + return None + + +def trigger_paste_shortcut(): + try: + subprocess.Popen( + ["sh", "-c", "wl-paste | wtype -"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except FileNotFoundError: + pass + + def get_wifi_icon_for_strength(strength: int) -> str: """ Get the appropriate WiFi icon based on signal strength. @@ -103,9 +201,7 @@ def is_special_workspace_id(ws_id) -> bool: return workspace_id < 0 except (ValueError, TypeError): # If it's a string, check if it starts with "special:" - if isinstance(ws_id, str) and ws_id.startswith("special:"): - return True - return False + return bool(isinstance(ws_id, str) and ws_id.startswith("special:")) def is_special_workspace(client: dict) -> bool: @@ -147,7 +243,7 @@ def escape_markup_text(text: str) -> str: return html.escape(clean.replace("\n", " ")) -# --- Process management --- +# Process management def spawn_detached(args: list[str]) -> subprocess.Popen: @@ -189,9 +285,7 @@ def find_process_pid(process_name: str, timeout: float | None = None) -> list[st return [] -# --- Binary lookup --- - - +# Binary lookup def find_binary(name: str) -> str | None: try: result = subprocess.run(["which", name], capture_output=True, text=True) @@ -273,3 +367,104 @@ def run_command( return CommandResult(127, "", str(e)) except Exception as e: return CommandResult(1, "", str(e)) + + + +def fuzzy_score(query: str, text: str) -> int: + """ + Fuzzy match scoring. Returns score > 0 if all query chars found in order. + Heavily rewards prefix matches, word-boundary matches, and consecutive runs. + Returns 0 if no match. + """ + if not query or not text: + return 0 + + query = query.lower() + text = text.lower() + + # Exact match + if query == text: + return 10000 + + # Prefix match + if text.startswith(query): + return 5000 + (100 - len(text)) + + # Check all query chars exist in order + qi = 0 + for char in text: + if qi < len(query) and char == query[qi]: + qi += 1 + if qi < len(query): + return 0 + + # Find best alignment — try each possible start position in text + best_score = 0 + + for start in range(len(text)): + if text[start] != query[0]: + continue + + qi = 0 + score = 0 + prev = start - 1 + + for ti in range(start, len(text)): + if qi >= len(query): + break + if text[ti] == query[qi]: + # Word boundary bonus (huge) + if ( + prev < 0 + or text[prev] == " " + or text[prev] == "-" + or text[prev] == "_" + ): + score += 50 + + # Consecutive bonus + if prev == ti - 1: + score += 20 + + # Exact position in query bonus + score += 5 + + prev = ti + qi += 1 + + if qi < len(query): + continue + + # How much of the text was consumed (tighter = better) + span = prev - start + 1 + score += max(0, 200 - span * 3) + + # Prefer shorter texts + score += max(0, 100 - len(text)) + + # Prefer matches at start + score += max(0, 50 - start) + + if score > best_score: + best_score = score + + return best_score + + +def fuzzy_filter(query: str, items: list, key=None, limit: int = 50) -> list: + """ + Filter and rank items by fuzzy match score. + Returns top `limit` items sorted by score (best first). + """ + if not query: + return items[:limit] + + scored = [] + for item in items: + text = key(item) if key else str(item) + score = fuzzy_score(query, text) + if score > 0: + scored.append((score, item)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [item for _, item in scored[:limit]] diff --git a/src/window/spotlight/main.py b/src/window/spotlight/main.py new file mode 100644 index 0000000..44110c1 --- /dev/null +++ b/src/window/spotlight/main.py @@ -0,0 +1,729 @@ +import gc +import os + +from fabric.core import Signal +from fabric.utils import Gdk, GLib +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.entry import Entry +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.wayland import WaylandWindow as Window + +from shared.widgets.clipping_box import ClippingBox +from shared.window.animated_scrollwindow import AnimatedScrollable +from utils.debounce import debounce +from utils.functions import get_children_height_limit +from window.spotlight.api.context import PluginContext +from window.spotlight.api.result import SearchResult +from window.spotlight.core.loader import PluginLoader +from window.spotlight.core.manager import PluginManager +from window.spotlight.core.registry import PluginRegistry +from window.spotlight.core.search import SearchPipeline + + +class PluginService: + """Owns plugin lifecycle and search pipeline. Plain class, no GObject.""" + + def __init__(self): + builtin_dir = os.path.join(os.path.dirname(__file__), "plugins") + context = PluginContext() + loader = PluginLoader(builtin_dir) + registry = PluginRegistry() + self.manager = PluginManager(loader, registry, context) + self.pipeline = SearchPipeline(self.manager) + self.manager.load_all() + + def stop(self): + self.pipeline.cancel() + self.manager.stop_all() + + def get_plugin(self, plugin_id: str): + return self.manager.get_plugin(plugin_id) + + def get_all_plugins(self): + return {e.id: e for e in self.manager.registry.get_enabled()} + + +class Spotlight(Box): + @Signal + def launched(self): ... + + def __init__(self, **kwargs): + super().__init__(name="spotlight", spacing=4, orientation="v", **kwargs) + + self._selected_index: int = -1 + self._current_results: list[SearchResult] = [] + self._active_plugin_entry = None + self._refresh_timer_id: int = 0 + self._result_widgets: dict[str, Button] = {} + + self.viewport = Box(spacing=4, orientation="v") + self.max_children = 4 + + self.plugin_service = PluginService() + + self.header_icon = Image(icon_name="system-search-symbolic") + self.header_entry = Entry( + placeholder="Search...", + style_classes="app-search-entry", + h_expand=True, + on_activate=self.on_entry_accept, + on_changed=self.on_entry_changed, + ).build(lambda entry, _: entry.connect("key-press-event", self.on_key_press)) + + self.header = Box( + style_classes="app-search-header", + spacing=8, + orientation="h", + children=[self.header_icon, self.header_entry], + ) + + self.scrolled_window = AnimatedScrollable( + max_content_size=(280, 320), + child=self.viewport, + h_expand=True, + v_expand=True, + visible=False, + animate=False, + ) + + self.scrolled_clip = ClippingBox( + style_classes="spotlight-scroll-clip", + children=self.scrolled_window, + visible=False, + ) + self.scrolled_window.connect("unmap", lambda: self.scrolled_clip.hide()) + + self.children = self.header, self.scrolled_clip + + # ------------------------------------------------------------------ + # Search + # ------------------------------------------------------------------ + + @debounce(200) + def on_entry_changed(self, entry: Entry, *_): + if getattr(entry, "_ignore_change", False): + entry._ignore_change = False + return + + text = entry.get_text() + self._selected_index = -1 + + keyword_entry, _kw, _remaining = self.plugin_service.pipeline._route(text) + if keyword_entry: + self._activate_keyword_plugin(keyword_entry, _kw, _remaining) + self.plugin_service.pipeline.search_single( + keyword_entry, text, self._on_search_results + ) + return + + self._deactivate_keyword_plugin() + + if not text.strip(): + self._clear_viewport() + self.header_icon.set_from_icon_name("system-search-symbolic") + return + + for pe in self.plugin_service.manager.registry.get_searchable(): + inst = pe.instance + if inst and inst.detect(text): + self.header_icon.set_from_icon_name( + getattr(inst, "icon", "") or "system-search-symbolic" + ) + self.plugin_service.pipeline.search(text, self._on_search_results) + return + + self.header_icon.set_from_icon_name("system-search-symbolic") + self.plugin_service.pipeline.search(text, self._on_search_results) + + def _activate_keyword_plugin(self, entry, keyword: str, query: str): + inst = entry.instance + if not inst: + return + + if self._active_plugin_entry and self._active_plugin_entry is not entry: + old = self._active_plugin_entry.instance + if old: + old.on_deactivate() + + self._active_plugin_entry = entry + + keyword_icons = getattr(inst, "keyword_icons", {}) + icon_name = ( + keyword_icons.get(keyword, getattr(inst, "icon", "")) + or "system-search-symbolic" + ) + self.header_icon.set_from_icon_name(icon_name) + + if getattr(inst, "full_viewport_clear", False): + self._clear_viewport() + self.scrolled_window.animate_size(0) + self.scrolled_window.hide() + self.scrolled_clip.hide() + + inst.on_activate(self.update_result_subtitle) + self._start_refresh_timer(entry, query) + + def _deactivate_keyword_plugin(self): + self._stop_refresh_timer() + if self._active_plugin_entry: + inst = self._active_plugin_entry.instance + if inst: + inst.on_deactivate() + self._active_plugin_entry = None + + def _start_refresh_timer(self, entry, query: str): + self._stop_refresh_timer() + inst = entry.instance + interval = getattr(inst, "refresh_interval", 0) if inst else 0 + if interval <= 0: + return + + def _tick(): + if self._active_plugin_entry is not entry: + return False + inst = entry.instance + if not inst: + return False + inst.on_timer_tick() + return True + + self._refresh_timer_id = GLib.timeout_add(interval, _tick) + + def _stop_refresh_timer(self): + if self._refresh_timer_id: + GLib.source_remove(self._refresh_timer_id) + self._refresh_timer_id = 0 + + def _auto_start_refresh_timer(self, results: list[SearchResult]): + for result in results: + if not result.plugin_id: + continue + entry = self.plugin_service.manager.registry.get(result.plugin_id) + if not entry: + continue + inst = entry.instance + if not inst: + continue + interval = getattr(inst, "refresh_interval", 0) + if interval > 0: + if self._active_plugin_entry is entry and self._refresh_timer_id: + return + if self._active_plugin_entry and self._active_plugin_entry is not entry: + old = self._active_plugin_entry.instance + if old: + old.on_deactivate() + self._active_plugin_entry = entry + inst.on_activate(self.update_result_subtitle) + self._start_refresh_timer(entry, "") + return + self._auto_stop_refresh_timer() + + def _auto_stop_refresh_timer(self): + if self._active_plugin_entry: + inst = self._active_plugin_entry.instance + interval = getattr(inst, "refresh_interval", 0) if inst else 0 + if interval <= 0: + self._deactivate_keyword_plugin() + + def _on_search_results(self, results: list[SearchResult]): + self._clear_viewport() + self._current_results = results + + if not results: + self._auto_stop_refresh_timer() + self._post_arrange() + return + + self._auto_start_refresh_timer(results) + + for result in results: + widget = self._render_result(result) + self._result_widgets[result.id] = widget + self.viewport.add(widget) + widget.connect("enter-notify-event", self.on_slot_enter) + + self._post_arrange() + + # ------------------------------------------------------------------ + # Rendering + # ------------------------------------------------------------------ + + def _render_result(self, result: SearchResult) -> Button: + rt = result.render_type + if rt == "app": + return self._render_app(result) + if rt == "calc": + return self._render_calc(result) + if rt == "emoji": + return self._render_emoji(result) + if rt == "power": + return self._render_power(result) + if rt == "wallpaper": + return self._render_wallpaper(result) + if rt == "clipboard_text": + return self._render_clipboard_text(result) + if rt == "clipboard_image": + return self._render_clipboard_image(result) + return self._render_default(result) + + def _render_app(self, r: SearchResult) -> Button: + app = r.metadata.get("desktop_app") + children = [] + if app: + pixbuf = app.get_icon_pixbuf() + if pixbuf: + children.append(Image(pixbuf=pixbuf, h_align="start", size=32)) + title_box = Box( + orientation="v", + children=[ + Label( + label=r.title or "Unknown", + v_align="start", + h_align="start", + style_classes="app-title", + ) + ], + ) + if r.subtitle: + title_box.add( + Label( + label=r.subtitle, + max_chars_width=42, + ellipsization="middle", + justification="center", + style_classes="app-description", + v_align="start", + h_align="start", + ) + ) + children.append(title_box) + + return Button( + style_classes="app-slot", + child=Box(orientation="h", spacing=12, children=children), + tooltip_text=r.subtitle, + on_clicked=lambda *_: self._activate_result(r), + ) + + def _render_calc(self, r: SearchResult) -> Button: + return Button( + style_classes="app-slot calc-result", + child=Box( + orientation="h", + spacing=12, + children=[ + Image( + icon_name=r.icon_name or "accessories-calculator-symbolic", + h_align="start", + size=32, + ), + Box( + orientation="v", + children=[ + Label( + label=r.title, + style_classes="calc-expression", + h_align="start", + ), + Label( + label=r.subtitle, + style_classes="calc-result-text", + h_align="start", + ), + ], + ), + ], + ), + on_clicked=lambda *_: self._activate_result(r), + ) + + def _render_emoji(self, r: SearchResult) -> Button: + emoji_char = r.metadata.get("emoji", r.title) + keyword_str = ", ".join(r.metadata.get("keywords", [])[:3]) + return Button( + style_classes="app-slot emoji-slot", + child=Box( + orientation="h", + spacing=12, + children=[ + Label( + label=emoji_char, + style_classes="emoji-char", + h_align="start", + v_align="center", + ), + Box( + orientation="v", + children=[ + Label( + label=r.subtitle, + style_classes="emoji-name", + h_align="start", + v_align="start", + ), + Label( + label=keyword_str, + style_classes="emoji-keywords", + h_align="start", + v_align="start", + ), + ], + ), + ], + ), + tooltip_text=f"Click to copy {emoji_char}", + on_clicked=lambda *_: self._activate_result(r), + ) + + def _render_power(self, r: SearchResult) -> Button: + return Button( + style_classes="app-slot power-menu-slot", + child=Box( + orientation="h", + spacing=12, + children=[ + Image(icon_name=r.icon_name, h_align="start", size=32), + Box( + orientation="v", + children=[ + Label( + label=r.title, + style_classes="power-menu-name", + h_align="start", + v_align="start", + ), + Label( + label=r.subtitle, + style_classes="power-menu-description", + h_align="start", + v_align="start", + ), + ], + ), + ], + ), + tooltip_text=f"Click to {r.title.lower()} the system", + on_clicked=lambda *_: self._activate_result(r), + ) + + def _render_wallpaper(self, r: SearchResult) -> Button: + thumb = r.metadata.get("thumbnail_path", "") + child_widget = Box(h_expand=True, v_expand=True) + if thumb and os.path.exists(thumb): + child_widget = ( + Box(h_expand=True, v_expand=True) + .build() + .set_style(f"background-image: url('file://{thumb}');", compile=False) + .unwrap() + ) + return Button( + style_classes="app-slot wallpaper", + child=child_widget, + on_clicked=lambda *_: self._activate_result(r), + ) + + def _render_clipboard_text(self, r: SearchResult) -> Button: + display = r.title + return Button( + style_classes="app-slot clipboard-text", + child=Box( + orientation="h", + children=[ + Label( + label=display, + style_classes="clipboard-content", + h_align="center", + v_align="center", + max_chars_width=40, + ellipsization="end", + ) + ], + ), + tooltip_text=r.subtitle, + on_clicked=lambda *_: self._activate_result(r), + ) + + def _render_clipboard_image(self, r: SearchResult) -> Button: + img_path = r.metadata.get("image_path", "") + child_widget = Box( + orientation="h", + children=[ + Label( + label="Image", + style_classes="clipboard-content", + h_align="center", + v_align="center", + ) + ], + ) + if img_path and os.path.exists(img_path): + child_widget = ( + Box(h_expand=True, v_expand=True) + .build() + .set_style( + f"background-image: url('file://{img_path}');", + compile=False, + ) + .unwrap() + ) + return Button( + style_classes="app-slot wallpaper clipboard-image", + child=child_widget, + on_clicked=lambda *_: self._activate_result(r), + ) + + def _render_default(self, r: SearchResult) -> Button: + children = [] + if r.icon_name: + children.append(Image(icon_name=r.icon_name, h_align="start", size=32)) + title_box = Box( + orientation="v", + children=[ + Label( + label=r.title, + style_classes="app-title", + h_align="start", + v_align="start", + ) + ], + ) + if r.subtitle: + title_box.add( + Label( + label=r.subtitle, + style_classes="app-description", + h_align="start", + v_align="start", + ) + ) + children.append(title_box) + + return Button( + style_classes="app-slot", + child=Box(orientation="h", spacing=12, children=children), + on_clicked=lambda *_: self._activate_result(r), + ) + + def _activate_result(self, result: SearchResult): + if result.action: + result.action() + self.launched() + + # ------------------------------------------------------------------ + # Viewport management + # ------------------------------------------------------------------ + + def _clear_viewport(self): + old_results = self._current_results + self._current_results = [] + self._result_widgets.clear() + for child in self.viewport.children: + try: + child.disconnect_by_func(self.on_slot_enter) + except (TypeError, ValueError): + pass + self.viewport.remove(child) + child.destroy() + del old_results + + def update_result_subtitle(self, result_id: str, text: str) -> None: + """Update subtitle label of a rendered result in-place (no re-render).""" + widget = self._result_widgets.get(result_id) + if not widget: + return + child = widget.get_child() + if not child: + return + # Walk: Button → main Box → title_box (last Box) → subtitle Label (2nd) + from fabric.widgets.box import Box + from fabric.widgets.label import Label + + boxes = [c for c in child.get_children() if isinstance(c, Box)] + if not boxes: + return + title_box = boxes[-1] + labels = [c for c in title_box.get_children() if isinstance(c, Label)] + if len(labels) >= 2: + labels[1].set_text(text) + + def _post_arrange(self): + new_height = get_children_height_limit(self.viewport, self.max_children) + if new_height < 1: + self.scrolled_window.animate_size(0) + self.scrolled_clip.hide() + self.scrolled_window.hide() + return + + self.scrolled_clip.show() + self.scrolled_window.show() + self.scrolled_window.animate_size(new_height) + + for i, slot in enumerate(self.viewport.children, start=1): + if i > 8: + break + slot.set_style(f"animation-duration: {round(i * 300)}ms;") + slot.add_style_class("shine") + + if self.viewport.children: + self.set_selected_index(0) + + # ------------------------------------------------------------------ + # Selection & keyboard + # ------------------------------------------------------------------ + + def on_slot_enter(self, button, event): + children = self.viewport.children + if button in children: + self.set_selected_index(children.index(button)) + + def set_selected_index(self, index: int): + children = self.viewport.children + if not children: + self._selected_index = -1 + return + + if 0 <= self._selected_index < len(children): + children[self._selected_index].remove_style_class("selected") + + self._selected_index = index % len(children) + selected = children[self._selected_index] + selected.add_style_class("selected") + + adj = self.scrolled_window.get_vadjustment() + alloc = selected.get_allocation() + res = selected.translate_coordinates(self.viewport, 0, 0) + if res: + y = res[1] if len(res) == 2 else res[2] + if y < adj.get_value(): + adj.set_value(y) + elif y + alloc.height > adj.get_value() + adj.get_page_size(): + adj.set_value(y + alloc.height - adj.get_page_size()) + + def on_key_press(self, entry, event): + keyval = event.keyval + if keyval == Gdk.KEY_Up or keyval == Gdk.KEY_ISO_Left_Tab: + self.set_selected_index(self._selected_index - 1) + return True + if keyval == Gdk.KEY_Down or keyval == Gdk.KEY_Tab: + self.set_selected_index(self._selected_index + 1) + return True + return False + + # ------------------------------------------------------------------ + # Submit + # ------------------------------------------------------------------ + + def on_entry_accept(self, entry: Entry, *_): + if 0 <= self._selected_index < len(self.viewport.children): + widget = self.viewport.children[self._selected_index] + if isinstance(widget, Button): + widget.clicked() + return + + if self._active_plugin_entry: + inst = self._active_plugin_entry.instance + if inst: + inst.on_submit(entry.get_text()) + entry.set_text("") + return + + if self._current_results: + self._activate_result(self._current_results[0]) + + # ------------------------------------------------------------------ + # External commands + # ------------------------------------------------------------------ + + def handle_external(self, command: str, text: str = "") -> bool: + if command == "deep_reload" and text: + return self.plugin_service.manager.deep_reload(text.strip()) + + plugin = self.plugin_service.get_plugin(command) + if plugin and hasattr(plugin, "handle_external"): + plugin.handle_external(command, text) + return True + + for pe in self.plugin_service.manager.registry.get_enabled(): + inst = pe.instance + if inst and command in getattr(inst, "keywords", []): + if hasattr(inst, "handle_external"): + inst.handle_external(command, text) + return True + + return False + + +class SpotlightWindow(Window): + def __init__(self, **kwargs): + self.spotlight_box = Spotlight( + on_launched=lambda: self.close_spotlight(), size=(460, -1) + ) + super().__init__( + name="spotlight-window", + title="fabric-spotlight", + anchor="top", + margin="80px 0px 0px 0px", + keyboard_mode="on-demand", + child=self.spotlight_box, + visible=False, + all_visible=False, + **kwargs, + ) + + self.build() + self.add_keybinding("Escape", lambda *_: self.close_spotlight()) + + def close_spotlight(self): + self.spotlight_box.plugin_service.pipeline.cancel() + + debounce_id = getattr( + self.spotlight_box, "_debounce_timer_on_entry_changed", None + ) + if debounce_id: + GLib.source_remove(debounce_id) + self.spotlight_box._debounce_timer_on_entry_changed = 0 + self.spotlight_box._stop_refresh_timer() + + self.spotlight_box.header_entry.set_text("") + self.spotlight_box._clear_viewport() + self.spotlight_box.scrolled_window.animate_size(0) + self.spotlight_box.scrolled_window.hide() + self.spotlight_box.scrolled_clip.hide() + self.spotlight_box.header_icon.set_from_icon_name("system-search-symbolic") + self.spotlight_box._deactivate_keyword_plugin() + self.spotlight_box.plugin_service.manager.release_memory_all() + gc.collect() + self.hide() + + def toggle( + self, command: str | None = None, text: str = "", external: bool = False + ): + if external: + full = f"{command or ''} {text or ''}".strip() + if full: + cmd, rest = ([*full.split(" ", 1), ""])[:2] + self.spotlight_box.handle_external(cmd.strip(), rest.strip()) + return + + if self.get_visible(): + self.close_spotlight() + return + + cmd = (command or "").strip() + txt = (text or "").strip() + + parts = [p for p in [cmd, txt] if p] + prefill = " ".join(parts) + + if cmd and not txt: + prefill += " " + + self.spotlight_box.header_entry.set_text(prefill) + + self.show_all() + self.spotlight_box.scrolled_clip.hide() + self.spotlight_box.scrolled_window.hide() + self.spotlight_box.header_entry.grab_focus() + self.spotlight_box.header_entry.set_position(-1)