Skip to content

Latest commit

 

History

History
533 lines (401 loc) · 16.2 KB

File metadata and controls

533 lines (401 loc) · 16.2 KB

Plugin Development Guide

Modus spotlight supports third-party plugins. Drop a Python file in config/plugins/ and it gets auto-discovered on next startup.

Quick Start

Copy the hello example to your plugins directory:

cp -r examples/plugins/hello.py config/plugins/hello.py

Open spotlight and type hi or hello. The plugin shows a greeting result.

How It Works

  1. On startup, Modus scans config/plugins/ for .py files and package directories.
  2. Each file is imported. The PLUGIN module-level variable is extracted (must be a SpotlightPlugin subclass).
  3. Plugins are instantiated, initialized, and registered.
  4. User types query -> spotlight routes to matching plugin (by keyword or global search) -> search() is called -> results are rendered.

Plugin Class Attributes

Attribute Type Required Description
id str Yes Unique identifier (e.g., "weather").
name str Yes Human-readable name shown in UI.
icon str No GTK icon name for the header.
keywords list[str] No Trigger prefixes (e.g., ["wall", "wr"]). User types wall <query> to activate.
searchable bool No If True, plugin appears in global search (no prefix needed). Default False.
priority int No Lower = searched first in global results. Default 100.
refresh_interval int No Auto-refresh timer in ms (0 = disabled). Fires on_timer_tick().

Methods

Required

def search(self, query: str, token: Any) -> list[SearchResult]:
    """Return results matching query.

    token: Cancellation token. Check token.is_cancelled before expensive work.
    Must return list of SearchResult. Never create GTK widgets.
    """

Optional

Method When Called Use Case
initialize() After instantiation Load data, start services
cleanup() On unload/disable Free resources
reload() On refresh Default: cleanup + initialize
on_activate(update_fn) Keyword prefix matched Set up state for keyword mode
on_deactivate() Keyword prefix cleared Tear down keyword state
on_submit(query) Enter pressed in keyword mode Handle final input
detect(text) Global search, each keystroke Return True to exclusively handle input
handle_external(command, args) External command (e.g., keybind) Handle non-search invocations
release_memory() Spotlight hides Clear heavy cached data

SearchResult Fields

SearchResult(
    id="unique_id",  # Unique within your plugin
    title="Display Title",  # Main text
    subtitle="Description",  # Secondary text
    icon_name="",  # GTK icon name (optional)
    icon_data=None,  # Raw image bytes for custom icons (optional)
    score=80.0,  # 0-100, higher = better match
    render_type="default",  # Controls rendering (see below)
    action=lambda: ...,  # Called when user selects this result
    plugin_name="",  # Auto-filled by spotlight
    plugin_id="",  # Auto-filled by spotlight
    metadata={},  # Arbitrary data for renderers
)

Render Types

render_type Widget Style Use Case
"default" Icon + title + subtitle Generic results
"app" Desktop app icon + name Application spotlight
"calc" Expression + result Calculator
"emoji" Emoji char + name + keywords Emoji picker
"power" Icon + name + description Power menu
"wallpaper" Thumbnail image Wallpaper picker
"clipboard_text" Text preview Clipboard entries
"clipboard_image" Image preview Clipboard images

Use "default" unless you need a specific layout. Custom render types can be added to main.py's _render_result().

Keyword Plugins vs Global Search

Keyword-only (searchable = False):

  • User types gg <query> -> routes exclusively to your plugin
  • search() receives the text after the prefix (e.g., "linux news")
  • Good for: YouTube search, Google search, wallpaper browser

Global search (searchable = True):

  • Plugin appears in results alongside apps, calculator, emoji
  • search() receives the full query
  • Good for: Calculator, emoji, power menu

Both (searchable = True + keywords):

  • Activates via keyword prefix AND appears in global results
  • Good for: Wallpapers, clipboard, weather

Cancellation

The token parameter has an is_cancelled flag. Check it before expensive operations:

def search(self, query: str, token: Any) -> list[SearchResult]:
    results = []
    for item in self._large_dataset:
        if token.is_cancelled:
            return []  # Abort immediately
        if self._matches(item, query):
            results.append(item)
    return results

The spotlight cancels previous searches when the user types new input. Without cancellation checks, your plugin wastes CPU on stale queries.

Examples

Hello World (single-file, no deps)

File: config/plugins/hello.py

from typing import Any

from window.spotlight.api import SpotlightPlugin, SearchResult


class HelloPlugin(SpotlightPlugin):
    id = "hello"
    name = "Hello"
    icon = "face-smile-symbolic"
    keywords = ["hi", "hello"]
    searchable = True
    priority = 100

    def search(self, query: str, token: Any) -> list[SearchResult]:
        if not query.strip():
            return []
        return [
            SearchResult(
                id="hello_world",
                title="Hello, World!",
                subtitle="A friendly greeting",
                icon_name="face-smile-symbolic",
                score=100.0,
                render_type="default",
                action=lambda: print("Hello!"),
            )
        ]


PLUGIN = HelloPlugin

Test: fabric-cli exec modus1 'deep_reload hello'

Google / YouTube / Link Opener (keyword-only, shell-safe)

File: config/plugins/query.py (built-in, but you can write your own)

import urllib.parse
from typing import Any

from utils.functions import spawn_detached
from window.spotlight.api import SpotlightPlugin, SearchResult


class GooglePlugin(SpotlightPlugin):
    id = "google"
    name = "Google"
    icon = "system-search-symbolic"
    keywords = ["gg"]
    searchable = False

    def search(self, query: str, token: Any) -> list[SearchResult]:
        return []

    def on_submit(self, query: str) -> None:
        q = query.strip()
        if q:
            url = f"https://www.google.com/search?q={urllib.parse.quote(q)}"
            spawn_detached(["xdg-open", url])  # no shell injection risk


PLUGIN = GooglePlugin

Usage: type gg linux news then Enter. Opens browser.

Weather Plugin (package plugin, third-party deps, I/O cancellation)

Full example on disk at config/plugins/weather/. Demonstrates:

  • Package plugin structure
  • Auto-installed dependencies via requirements.txt + uv
  • I/O with cancellation checks
  • Result caching
"""Weather plugin for Modus spotlight.

Demonstrates:
  - Package plugin structure
  - Third-party deps via requirements.txt (uv venv auto-install)
  - Keyword activation + global search
  - Deep reload during development
  - Cancellation checks for I/O
  - External command support
"""

import os
from typing import Any

from window.spotlight.api import SpotlightPlugin, SearchResult

try:
    import requests
except ImportError:
    requests = None


class WeatherPlugin(SpotlightPlugin):
    id = "weather"
    name = "Weather"
    icon = "weather-clear-symbolic"
    keywords = ["wth", "weather"]
    searchable = True
    priority = 75

    def __init__(self, context):
        super().__init__(context)
        self._cache: dict | None = None

    def initialize(self) -> None:
        pass

    def cleanup(self) -> None:
        self._cache = None

    def search(self, query: str, token: Any) -> list[SearchResult]:
        q = query.strip().lower()

        # Strip keyword prefix ("wth london" -> "london")
        for prefix in ("wth ", "weather "):
            if q.startswith(prefix):
                q = q[len(prefix):].strip()
                break
        if q in ("wth", "weather"):
            q = ""

        if not q:
            return [SearchResult(...hint...)]

        data = self._fetch_weather(q, token)
        if token.is_cancelled or not data:
            return []

        temp = data.get("main", {}).get("temp", "?")
        desc = data.get("weather", [{}])[0].get("description", "?")
        city = data.get("name", q)

        return [
            SearchResult(
                id=f"weather_{q}",
                title=f"{city}: {temp}°C",
                subtitle=desc.capitalize(),
                icon_name="weather-clear-symbolic",
                score=100.0,
                render_type="default",
            )
        ]

    def _fetch_weather(self, city: str, token: Any) -> dict | None:
        """Fetch weather from OpenWeatherMap with caching."""
        if self._cache:
            return self._cache

        api_key = os.environ.get("OPENWEATHER_API_KEY")
        if not api_key or requests is None:
            return None

        resp = requests.get(
            "https://api.openweathermap.org/data/2.5/weather",
            params={"q": city, "appid": api_key, "units": "metric"},
            timeout=10,
        )
        if token.is_cancelled:
            return None
        if resp.status_code == 200:
            self._cache = resp.json()
            return self._cache
        return None

    def handle_external(self, command: str, args: str) -> None:
        print(f"External: {command} {args}")


PLUGIN = WeatherPlugin

File layout:

examples/plugins/weather/
  __init__.py          # plugin code
  requirements.txt     # requests>=2.28

Copy to plugins dir and try:

cp -r examples/plugins/weather config/plugins/weather
fabric-cli exec modus1 'deep_reload weather'

Now type wth london in spotlight.

On first load, PluginLoader creates config/plugins/.venvs/weather/ via uv venv, installs requests via uv pip install, then imports your plugin.

Usage: type wth london or just search london in global mode.

Set env var in your Hyprland config:

env = OPENWEATHER_API_KEY, your_key_here

Color Picker (global search, clipboard action)

from typing import Any

from utils.functions import run_command
from window.spotlight.api import SpotlightPlugin, SearchResult


class ColorPlugin(SpotlightPlugin):
    id = "colors"
    name = "Colors"
    icon = "color-select-symbolic"
    keywords = []
    searchable = True
    priority = 80

    COLORS = [
        ("Red", "#FF0000"),
        ("Green", "#00FF00"),
        ("Blue", "#0000FF"),
    ]

    def search(self, query: str, token: Any) -> list[SearchResult]:
        q = query.lower().strip()
        if not q:
            return []
        results = []
        for name, hex_code in self.COLORS:
            if q in name.lower():
                results.append(
                    SearchResult(
                        id=f"color_{name.lower()}",
                        title=name,
                        subtitle=hex_code,
                        icon_name="color-select-symbolic",
                        score=90.0,
                        render_type="default",
                        action=lambda h=hex_code: self._copy_color(h),
                    )
                )
        return results

    def _copy_color(self, hex_code: str) -> None:
        run_command(["wl-copy"], input=hex_code.encode(), text=False)


PLUGIN = ColorPlugin

Package Plugins

For complex plugins, use a directory with plugin.py or __init__.py:

config/plugins/
  myplugin/
    __init__.py    # or plugin.py — must export PLUGIN = MyPluginClass
    utils.py
    data.json
    requirements.txt   # optional third-party deps

Third-Party Dependencies

If your plugin needs external Python packages, add requirements.txt next to your plugin file or in your package directory. Modus auto-installs them into an isolated virtualenv using uv.

Single-file plugin with dependencies

config/plugins/
  weather.py
  weather/requirements.txt   # companion directory

or

config/plugins/
  weather.py
  requirements.txt           # next to .py file

Package plugin with dependencies

config/plugins/
  myplugin/
    __init__.py
    utils.py
    requirements.txt

What happens at load time

  1. Modus checks for requirements.txt next to your .py file or in your package directory.
  2. If found, it creates a venv at config/plugins/.venvs/<plugin_name>/ using uv venv.
  3. It runs uv pip install -r requirements.txt --python <venv>/bin/python.
  4. The venv's site-packages is added to sys.path before your plugin is imported.
  5. If requirements.txt changes later, deps are re-installed automatically.
  6. Requirement: uv must be installed (pip install uv or cargo install uv).

Only one requirements.txt per plugin is consulted. The venv persists across restarts.

Development Workflow

Edit → Reload → Test loop

The fastest way to develop a plugin:

  1. Edit your plugin file
  2. Run fabric-cli exec modus1 'deep_reload your_plugin_id'
  3. Open spotlight and test

No Modus restart needed.

Example session:

# Copy the weather example
cp -r examples/plugins/weather config/plugins/weather

# --- First iteration ---
vim config/plugins/weather/__init__.py
fabric-cli exec modus1 'deep_reload weather'

# --- Watch logs ---
journalctl -f --user -t modus

# --- Second iteration ---
vim config/plugins/weather/__init__.py
fabric-cli exec modus1 'deep_reload weather'

# --- Test output from terminal ---
fabric-cli exec modus1 'wth london'

Deep reload under the hood

fabric-cli exec modus1 'deep_reload weather'
  1. Spotlight.handle_external("deep_reload", "weather") catches the built-in command
  2. PluginManager.deep_reload("weather") runs:
    • entry.instance.cleanup() — free old resources
    • PluginLoader.unload_module("modus_plugin_user_weather") — removes stale module from sys.modules
    • PluginLoader._load_package_plugin(path, "user") — re-imports from disk
    • entry.plugin_class = fresh_class — swaps the class reference
    • entry.instance = fresh_class(context) — new instance
    • entry.instance.initialize() — fresh setup

Check from terminal without UI

fabric-cli exec modus1 'your_plugin_id some_query'

This routes to the plugin via keyword match or direct id lookup.

Errors & logging

If a plugin fails to load or crashes during search, check the Modus log:

journalctl --user -f -t modus

On reload failure, the old instance stays active. The log will show what went wrong.

External Commands (Keybinds)

Plugins can be invoked from Hyprland keybinds via handle_external:

def handle_external(self, command: str, args: str) -> None:
    if command == "myplugin":
        self._do_stuff(args)

Hyprland config:

["SUPER + M"] = "fabric-cli exec modus1 'myplugin some_argument'"

Tips

  • Cache heavy data in initialize(), not in search(). The search method runs on every keystroke.
  • Use fuzzy_score from utils.functions for fuzzy matching against user input.
  • Keep search() fast. If you do I/O, check token.is_cancelled frequently between operations.
  • Use metadata to pass data to renderers. The spotlight owns all GTK widget creation — your plugin never touches widgets.
  • Use run_command from utils.functions for subprocess calls — handles timeouts and errors. Never use shell strings (exec_shell_command(f"...")) with user input — that's a shell injection risk.
  • Use spawn_detached from utils.functions to launch background processes without blocking.
  • Use thread() from utils.functions for background work from initialize() or handle_external().
  • Set OPENWEATHER_API_KEY in your Hyprland env for the weather example.
  • Test from terminal: fabric-cli exec modus1 'deep_reload your_plugin_id'