Files
Modus/src/window/spotlight/main.py
T
sankalp 553b1e49d8 fix(spotlight): SVG search icon and import order
- Replace system-search-symbolic with misc/search.svg
- Add _ICON_SVG_MAP for GTK theme to SVG path resolution
- Move all imports to top of file (fix E402)
2026-07-22 10:25:10 +05:45

778 lines
27 KiB
Python

import os
from fabric.core import Signal
from fabric.utils import Gdk, GLib, logger
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 utils.gtk_utils import svg_file
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
# Map GTK theme icon names to SVG asset paths
_ICON_SVG_MAP = {
"system-search-symbolic": "misc/search.svg",
"system-shutdown-symbolic": "misc/shutdown.svg",
"system-reboot-symbolic": "misc/reboot.svg",
"system-suspend-symbolic": "misc/suspend.svg",
"system-hibernate-symbolic": "misc/hibernate.svg",
"system-log-out-symbolic": "misc/logout.svg",
"system-lock-screen-symbolic": "misc/lock.svg",
"edit-find-symbolic": "misc/search.svg",
"edit-paste-symbolic": "misc/paste.svg",
"video-x-generic-symbolic": "misc/video.svg",
"insert-link-symbolic": "misc/link.svg",
"face-smile-symbolic": "misc/emoji.svg",
"accessories-calculator-symbolic": "misc/calculator.svg",
"user-available-symbolic": "misc/coffee-on.svg",
"user-away-symbolic": "misc/coffee-off.svg",
}
def _icon_to_svg(icon_name: str, size: int = 32):
"""Return an Svg widget for a GTK theme icon name, or None if unmapped."""
path = _ICON_SVG_MAP.get(icon_name)
if path:
return svg_file(path, size=size)
return None
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 = svg_file("misc/search.svg", size=16)
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(300)
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_file("misc/search.svg")
return
for pe in self.plugin_service.manager.registry.get_searchable():
inst = pe.instance
if inst and inst.detect(text):
icon = getattr(inst, "icon", "") or "system-search-symbolic"
svg_path = _ICON_SVG_MAP.get(icon, "misc/search.svg")
self.header_icon.set_from_file(svg_path)
# Scope to the detected plugin only — a global search would
# mix in unrelated plugins' results.
self.plugin_service.pipeline.search_single(
pe, text, self._on_search_results
)
return
self.header_icon.set_from_file("misc/search.svg")
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"
)
svg_path = _ICON_SVG_MAP.get(icon_name, "misc/search.svg")
self.header_icon.set_from_file(svg_path)
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]):
new_ids = [r.id for r in results]
old_ids = list(self._result_widgets.keys())
if new_ids == old_ids:
self._current_results = results
return
for rid in set(old_ids) - set(new_ids):
self._remove_result_widget(rid)
for child in list(self.viewport.children):
self.viewport.remove(child)
for result in results:
widget = self._result_widgets.get(result.id)
if not widget:
widget = self._render_result(result)
self._result_widgets[result.id] = widget
widget.connect("enter-notify-event", self.on_slot_enter)
self.viewport.add(widget)
self._current_results = results
if not results:
self._auto_stop_refresh_timer()
self._post_arrange()
return
self._auto_start_refresh_timer(results)
self._post_arrange()
def _remove_result_widget(self, result_id: str):
widget = self._result_widgets.pop(result_id, None)
if not widget:
return
try:
widget.disconnect_by_func(self.on_slot_enter)
except (TypeError, ValueError) as e:
logger.warning(
f"[main] widget.disconnect_by_func(self.on_slot_enter) failed: {e}"
)
self.viewport.remove(widget)
widget.destroy()
# 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_by_id(r.id),
)
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_by_id(r.id),
)
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_by_id(r.id),
)
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_by_id(r.id),
)
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_by_id(r.id),
)
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_by_id(r.id),
)
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_by_id(r.id),
)
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_by_id(r.id),
)
def _activate_result(self, result: SearchResult):
try:
if result.action:
result.action()
except Exception as e:
logger.warning(f"[spotlight] result.action() failed: {e}")
self.launched()
def _activate_result_by_id(self, result_id: str):
for r in self._current_results:
if r.id == result_id:
self._activate_result(r)
return
# Viewport management
def _clear_viewport(self):
self._current_results = []
for child in list(self.viewport.children):
try:
child.disconnect_by_func(self.on_slot_enter)
except (TypeError, ValueError) as e:
logger.warning(
f"[main] child.disconnect_by_func(self.on_slot_enter) failed: {e}"
)
self.viewport.remove(child)
child.destroy()
self._result_widgets.clear()
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)
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)
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._current_results:
self._activate_result(self._current_results[0])
return
if self._active_plugin_entry:
text = entry.get_text()
entry.set_text("")
def _on_immediate_results(results):
if results:
self._activate_result(results[0])
self.plugin_service.pipeline.search_single(
self._active_plugin_entry, text, _on_immediate_results
)
return
# 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="modus-spotlight",
anchor="top",
margin="80px 0px 0px 0px",
keyboard_mode="exclusive",
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()
self.spotlight_box._stop_refresh_timer()
self.spotlight_box.header_entry.set_text("")
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._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_file("misc/search.svg")
self.spotlight_box._deactivate_keyword_plugin()
self.spotlight_box.plugin_service.manager.release_memory_all()
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)