Plugin Development - ArunPrakashG/native-launcher GitHub Wiki

Plugin Development Guide

Native Launcher has a powerful plugin system that allows you to extend functionality without modifying core code.

Table of Contents

Overview

Plugins in Native Launcher:

  • Implement the Plugin trait
  • Can handle search queries with custom logic
  • Can respond to keyboard events
  • Are prioritized and dispatched in order
  • Run in the same process (no IPC overhead)

Plugin Lifecycle

  1. Plugin is registered in PluginManager
  2. Plugins are sorted by priority (highest first)
  3. For each search query:
    • should_handle() determines if plugin processes the query
    • search() returns results
  4. For each keyboard event:
    • handle_keyboard_event() can intercept and handle the event
    • First plugin to return non-None action wins

Creating a Basic Plugin

1. Define Your Plugin Struct

use crate::plugins::traits::{Plugin, PluginContext, PluginResult};
use anyhow::Result;

#[derive(Debug)]
pub struct MyPlugin {
    enabled: bool,
}

impl MyPlugin {
    pub fn new() -> Self {
        Self { enabled: true }
    }
}

2. Implement the Plugin Trait

impl Plugin for MyPlugin {
    fn name(&self) -> &str {
        "my_plugin"
    }

    fn description(&self) -> &str {
        "My awesome plugin"
    }

    fn should_handle(&self, query: &str) -> bool {
        // Return true if your plugin can handle this query
        // Examples:
        // - Check for specific prefix: query.starts_with("@myprefix")
        // - Check query length: query.len() >= 3
        // - Pattern matching: query.contains("keyword")
        query.starts_with("@my")
    }

    fn search(&self, query: &str, context: &PluginContext) -> Result<Vec<PluginResult>> {
        // Return search results
        let result = PluginResult::new(
            "My Result".to_string(),
            "echo 'Hello from plugin'".to_string(),
            self.name().to_string(),
        )
        .with_subtitle("This is a subtitle".to_string())
        .with_icon("application-x-executable".to_string())
        .with_score(500);  // Higher score = appears first

        Ok(vec![result])
    }

    fn priority(&self) -> i32 {
        500  // Higher = searched first
        // Typical priorities:
        // - Applications: 1000
        // - Calculator: 500
        // - Web Search: 600
        // - Files: 400
        // - Shell: 300
    }

    fn enabled(&self) -> bool {
        self.enabled
    }
}

3. Register Your Plugin

Add your plugin to src/plugins/manager.rs:

use super::MyPlugin;

impl PluginManager {
    pub fn new(...) -> Self {
        let mut plugins: Vec<Box<dyn Plugin>> = Vec::new();

        // Add your plugin
        plugins.push(Box::new(MyPlugin::new()));

        // ...
    }
}

Keyboard Event Handling

One of the most powerful features is the ability to handle keyboard shortcuts directly in your plugin.

Basic Keyboard Handler

use crate::plugins::traits::{KeyboardEvent, KeyboardAction};
use gtk4::gdk::Key;

impl Plugin for MyPlugin {
    fn handle_keyboard_event(&self, event: &KeyboardEvent) -> KeyboardAction {
        // Handle Ctrl+= for calculator-like behavior
        if event.key == Key::equal && event.has_ctrl() {
            let result = calculate(&event.query);
            return KeyboardAction::Execute {
                command: format!("echo '{}'", result),
                terminal: false,
            };
        }

        // Handle Alt+Enter for alternative action
        if event.key == Key::Return && event.has_alt() {
            return KeyboardAction::OpenUrl(
                format!("https://example.com/search?q={}", event.query)
            );
        }

        KeyboardAction::None  // Let other plugins handle it
    }
}

KeyboardEvent Structure

pub struct KeyboardEvent {
    pub key: Key,                    // The key pressed (from GTK)
    pub modifiers: ModifierType,     // Ctrl, Shift, Alt, Super
    pub query: String,               // Current search query
    pub has_selection: bool,         // Whether a result is selected
}

Helper Methods

event.has_ctrl()   // Check if Ctrl is pressed
event.has_shift()  // Check if Shift is pressed
event.has_alt()    // Check if Alt is pressed
event.has_super()  // Check if Super/Meta is pressed

KeyboardAction Types

pub enum KeyboardAction {
    None,                                    // Don't handle, pass to next plugin
    Execute { command, terminal },           // Execute command and close window
    OpenUrl(String),                         // Open URL in browser and close window
    Handled,                                 // Event handled, but don't close window
}

Event Priority System

  • Keyboard events are dispatched to plugins in priority order (highest first)
  • The first plugin to return a non-None action wins
  • This prevents conflicts when multiple plugins could handle the same key

Example:

Event: Ctrl+Enter
  ↓
WebSearchPlugin (priority 600) → Returns OpenUrl("https://google.com/...")
  ↓
Event handled! Other plugins don't see it.

Plugin API Reference

PluginResult Builder Methods

PluginResult::new(title, command, plugin_name)
    .with_subtitle(String)         // Optional description
    .with_icon(String)              // Icon name or path
    .with_score(i64)                // Search relevance score
    .with_terminal(bool)            // Run in terminal?
    .with_sub_results(Vec<PluginResult>)  // Nested results
    .with_parent_app(String)        // Parent app for icon resolution

PluginContext

pub struct PluginContext {
    pub max_results: usize,         // Maximum results requested
    pub include_low_scores: bool,   // Whether to include low-score results
}

Command Prefixes

Plugins can register command prefixes for targeted searches:

fn command_prefixes(&self) -> Vec<&str> {
    vec!["@my", "@myplugin"]
}

When a query starts with a registered prefix, only that plugin is queried.

Best Practices

1. Performance

// ❌ Bad: Expensive operation on every query
fn search(&self, query: &str, context: &PluginContext) -> Result<Vec<PluginResult>> {
    let all_items = scan_entire_filesystem();  // Slow!
    filter_results(all_items, query)
}

// ✅ Good: Cache expensive operations
pub struct MyPlugin {
    cached_items: Vec<Item>,  // Pre-computed
}

fn search(&self, query: &str, context: &PluginContext) -> Result<Vec<PluginResult>> {
    Ok(filter_results(&self.cached_items, query))
}

2. Error Handling

// ❌ Bad: Panics on error
fn search(&self, query: &str, context: &PluginContext) -> Result<Vec<PluginResult>> {
    let file = std::fs::read_to_string("config.json").unwrap();  // Panics!
    // ...
}

// ✅ Good: Returns error
fn search(&self, query: &str, context: &PluginContext) -> Result<Vec<PluginResult>> {
    let file = std::fs::read_to_string("config.json")?;  // Returns error
    // ...
}

3. Scoring

// Typical score ranges:
// 9000+ : Exact matches (explicit web search: "google query")
// 1000  : Applications (when app name matches)
// 500   : High-priority plugins (calculator with exact match)
// 100   : Low-priority / fallback results (generic web search)

fn search(&self, query: &str, context: &PluginContext) -> Result<Vec<PluginResult>> {
    let score = if query.starts_with("@exact") {
        9000  // User explicitly requested this
    } else if query.contains("keyword") {
        500   // Good match
    } else {
        100   // Fallback / generic
    };

    Ok(vec![result.with_score(score)])
}

4. Keyboard Events

// ❌ Bad: Greedy keyboard handling
fn handle_keyboard_event(&self, event: &KeyboardEvent) -> KeyboardAction {
    if event.key == Key::Return {
        return KeyboardAction::Execute { ... };  // Blocks all Enter keys!
    }
    KeyboardAction::None
}

// ✅ Good: Specific conditions
fn handle_keyboard_event(&self, event: &KeyboardEvent) -> KeyboardAction {
    // Only handle if specific conditions met
    if event.key == Key::Return && event.has_ctrl() && self.should_handle(&event.query) {
        return KeyboardAction::Execute { ... };
    }
    KeyboardAction::None
}

Examples

Example 1: Simple Calculator Plugin

#[derive(Debug)]
pub struct CalculatorPlugin {
    enabled: bool,
}

impl Plugin for CalculatorPlugin {
    fn name(&self) -> &str {
        "calculator"
    }

    fn should_handle(&self, query: &str) -> bool {
        // Handle queries that look like math expressions
        query.chars().any(|c| "+-*/^()".contains(c))
            || query.parse::<f64>().is_ok()
    }

    fn search(&self, query: &str, _context: &PluginContext) -> Result<Vec<PluginResult>> {
        let result = match evaluate_expression(query) {
            Ok(value) => PluginResult::new(
                format!("= {}", value),
                format!("echo {} | wl-copy", value),  // Copy to clipboard
                self.name().to_string(),
            )
            .with_subtitle(format!("Press Enter to copy"))
            .with_icon("accessories-calculator")
            .with_score(500),
            Err(_) => return Ok(vec![]),
        };

        Ok(vec![result])
    }

    fn handle_keyboard_event(&self, event: &KeyboardEvent) -> KeyboardAction {
        // Handle = key to auto-evaluate
        if event.key == Key::equal && !event.has_ctrl() {
            if let Ok(result) = evaluate_expression(&event.query) {
                return KeyboardAction::Execute {
                    command: format!("echo {} | wl-copy", result),
                    terminal: false,
                };
            }
        }
        KeyboardAction::None
    }

    fn priority(&self) -> i32 {
        500
    }
}

Example 2: SSH Connection Plugin

#[derive(Debug)]
pub struct SshPlugin {
    enabled: bool,
    known_hosts: Vec<String>,
}

impl Plugin for SshPlugin {
    fn name(&self) -> &str {
        "ssh"
    }

    fn command_prefixes(&self) -> Vec<&str> {
        vec!["@ssh", "@connect"]
    }

    fn should_handle(&self, query: &str) -> bool {
        query.starts_with("@ssh") || query.starts_with("@connect")
    }

    fn search(&self, query: &str, _context: &PluginContext) -> Result<Vec<PluginResult>> {
        let query = query.trim_start_matches("@ssh")
                         .trim_start_matches("@connect")
                         .trim();

        let matches = self.known_hosts
            .iter()
            .filter(|host| host.contains(query))
            .map(|host| {
                PluginResult::new(
                    format!("SSH to {}", host),
                    format!("ssh {}", host),
                    self.name().to_string(),
                )
                .with_subtitle(host.clone())
                .with_icon("network-server")
                .with_terminal(true)
                .with_score(700)
            })
            .collect();

        Ok(matches)
    }

    fn handle_keyboard_event(&self, event: &KeyboardEvent) -> KeyboardAction {
        // Alt+Enter: Open in new terminal window
        if event.key == Key::Return && event.has_alt() {
            if let Some(host) = extract_host(&event.query) {
                return KeyboardAction::Execute {
                    command: format!("alacritty -e ssh {}", host),
                    terminal: false,  // Already wrapped in terminal
                };
            }
        }
        KeyboardAction::None
    }

    fn priority(&self) -> i32 {
        700
    }
}

Example 3: Web Search with Multiple Engines

See the complete implementation in src/plugins/web_search.rs.

Key features:

  • Handles Ctrl+Enter for immediate web search
  • Supports multiple search engines (google, ddg, wiki, github, youtube)
  • Fallback to Google for queries without explicit engine
  • URL encoding for search terms

Testing Your Plugin

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_should_handle() {
        let plugin = MyPlugin::new();
        assert!(plugin.should_handle("@my test"));
        assert!(!plugin.should_handle("firefox"));
    }

    #[test]
    fn test_search_results() {
        let plugin = MyPlugin::new();
        let ctx = PluginContext::new(10);
        let results = plugin.search("@my query", &ctx).unwrap();

        assert!(!results.is_empty());
        assert!(results[0].title.contains("query"));
    }

    #[test]
    fn test_keyboard_event() {
        use gtk4::gdk::{Key, ModifierType};

        let plugin = MyPlugin::new();
        let event = KeyboardEvent::new(
            Key::Return,
            ModifierType::CONTROL_MASK,
            "@my test".to_string(),
            false,
        );

        match plugin.handle_keyboard_event(&event) {
            KeyboardAction::Execute { .. } => (), // Expected
            _ => panic!("Expected Execute action"),
        }
    }
}

Next Steps

Contributing Plugins

Want to contribute your plugin?

  1. Follow the Contributing Guide
  2. Ensure your plugin has tests
  3. Document keyboard shortcuts in the PR
  4. Add configuration options if needed

Popular plugin ideas: Clipboard manager, window switcher, system commands, bookmarks, password manager integration, emoji picker, color picker, unit converter.

⚠️ **GitHub.com Fallback** ⚠️