Files
Modus/docs/plugins.md
T
sankalp 3a81ab1212 feat(spotlight): add plugin system with core engine and examples
- Plugin API (plugin.py, context.py, result.py)
- Core engine (search.py, manager.py, registry.py, loader.py)
- Built-in plugins: app, calculator, clipboard, emoji, power_menu, query, wallpapers
- Spotlight docs and example plugins
- Spotlight CSS styling
2026-07-12 20:17:34 +05:45

534 lines
16 KiB
Markdown

# 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:
```bash
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
## 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
```python
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
```python
SearchResult(
id="unique_id", # Unique within your plugin
title="Display Title", # Main text
subtitle="Description", # Secondary text
icon_name="", # GTK icon name (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:
```python
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`
```python
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)
```python
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
```python
"""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:
```bash
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)
```python
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:
```bash
# 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
```bash
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:
```bash
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`:
```python
def handle_external(self, command: str, args: str) -> None:
if command == "myplugin":
self._do_stuff(args)
```
Hyprland config:
```lua
["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'`