refactor: consolidate debounce into utils/debounce.py

Move method-specific debounce from functions.py to debounce.py,
keeping both debounce (method) and sync_debounce (general) together.
This commit is contained in:
sankalp committed 2026-07-12 19:04:33 +05:45
1 parent 30c02f207d
commit 15e64df428
3 files changed
+959 -8

No files matched your search

+27
View File
@@ -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]:
+203 -8
View File
@@ -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
# ( <the spacing for each child combined, last child doesn't have spacing> ) + ( <the total height of all the children> )
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]]
+729
View File
@@ -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)