From f81acdc962e6786f215ae39b2fc327ee972c9a52 Mon Sep 17 00:00:00 2001 From: Javier Feliz Date: Fri, 5 Sep 2025 21:10:30 -0400 Subject: [PATCH] Working out a good interface to reduce plugin boilerplate --- Cargo.lock | 10 ++ Cargo.toml | 1 + waycast-gtk/src/main.rs | 4 +- waycast-macros/Cargo.toml | 12 ++ waycast-macros/src/lib.rs | 269 +++++++++++++++++++++++++++++ waycast-plugins/Cargo.toml | 1 + waycast-plugins/src/drun.rs | 99 +++++------ waycast-plugins/src/file_search.rs | 160 +++++++++-------- waycast-plugins/src/lib.rs | 3 + 9 files changed, 427 insertions(+), 132 deletions(-) create mode 100644 waycast-macros/Cargo.toml create mode 100644 waycast-macros/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index f883243..6314728 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,6 +913,15 @@ dependencies = [ "waycast-plugins", ] +[[package]] +name = "waycast-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "waycast-plugins" version = "0.1.0" @@ -923,6 +932,7 @@ dependencies = [ "tokio", "walkdir", "waycast-core", + "waycast-macros", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b2cce80..e31de28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "waycast-core", + "waycast-macros", "waycast-plugins", "waycast-gtk" ] diff --git a/waycast-gtk/src/main.rs b/waycast-gtk/src/main.rs index 7901de3..3ec8d87 100644 --- a/waycast-gtk/src/main.rs +++ b/waycast-gtk/src/main.rs @@ -13,9 +13,9 @@ fn main() { .build(); app.connect_activate(|app| { - let mut file_search_plugin = waycast_plugins::file_search::new(); + let file_search_plugin = waycast_plugins::file_search::new(); - match file_search_plugin.add_search_path("/home/javi/working-files/DJ Music/") { + match waycast_plugins::file_search::add_search_path("/home/javi/working-files/DJ Music/") { Err(e) => eprintln!("{}", e), _ => (), } diff --git a/waycast-macros/Cargo.toml b/waycast-macros/Cargo.toml new file mode 100644 index 0000000..ecf87e0 --- /dev/null +++ b/waycast-macros/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "waycast-macros" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +syn = { version = "2.0", features = ["full"] } +quote = "1.0" \ No newline at end of file diff --git a/waycast-macros/src/lib.rs b/waycast-macros/src/lib.rs new file mode 100644 index 0000000..9619f63 --- /dev/null +++ b/waycast-macros/src/lib.rs @@ -0,0 +1,269 @@ +use proc_macro::TokenStream; +use proc_macro2::Ident; +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + parse_macro_input, + Expr, ExprLit, Lit, LitInt, LitStr, Result, Token, +}; + +/// Plugin configuration parsed from the macro input +struct PluginConfig { + struct_name: Ident, + name: LitStr, + priority: Option, + description: Option, + prefix: Option, + by_prefix_only: Option, + init_fn: Option, + default_list_fn: Option, + filter_fn: Option, +} + +impl Parse for PluginConfig { + fn parse(input: ParseStream) -> Result { + // Parse "struct StructName;" + input.parse::()?; + let struct_name = input.parse::()?; + input.parse::()?; + + let mut name = None; + let mut priority = None; + let mut description = None; + let mut prefix = None; + let mut by_prefix_only = None; + let mut init_fn = None; + let mut default_list_fn = None; + let mut filter_fn = None; + + // Parse comma-separated key: value pairs + while !input.is_empty() { + if input.peek(Token![,]) { + input.parse::()?; + } + + if input.is_empty() { + break; + } + + let key: Ident = input.parse()?; + input.parse::()?; + + match key.to_string().as_str() { + "name" => { + let lit: LitStr = input.parse()?; + name = Some(lit); + } + "priority" => { + let lit: LitInt = input.parse()?; + priority = Some(lit); + } + "description" => { + let lit: LitStr = input.parse()?; + description = Some(lit); + } + "prefix" => { + let lit: LitStr = input.parse()?; + prefix = Some(lit); + } + "by_prefix_only" => { + let expr: Expr = input.parse()?; + if let Expr::Lit(ExprLit { lit: Lit::Bool(lit_bool), .. }) = expr { + by_prefix_only = Some(lit_bool.value); + } else { + return Err(syn::Error::new_spanned(expr, "Expected boolean literal")); + } + } + "init" => { + let fn_name: Ident = input.parse()?; + init_fn = Some(fn_name); + } + "default_list" => { + let fn_name: Ident = input.parse()?; + default_list_fn = Some(fn_name); + } + "filter" => { + let fn_name: Ident = input.parse()?; + filter_fn = Some(fn_name); + } + _ => { + let key_str = key.to_string(); + return Err(syn::Error::new_spanned( + key, + format!("Unknown plugin configuration key: {}", key_str), + )); + } + } + } + + // Validate required fields + let name = name.ok_or_else(|| { + syn::Error::new_spanned(&struct_name, "Plugin must have a 'name' field") + })?; + + Ok(PluginConfig { + struct_name, + name, + priority, + description, + prefix, + by_prefix_only, + init_fn, + default_list_fn, + filter_fn, + }) + } +} + +impl PluginConfig { + /// Generate the full plugin struct name with "Plugin" suffix + fn plugin_struct_name(&self) -> Ident { + let name_str = format!("{}Plugin", self.struct_name); + Ident::new(&name_str, self.struct_name.span()) + } + + /// Generate the implementation of LauncherPlugin trait + fn generate_plugin_impl(&self) -> proc_macro2::TokenStream { + let plugin_struct_name = self.plugin_struct_name(); + let name_str = &self.name; + + // Generate priority method + let priority = if let Some(ref priority_lit) = self.priority { + quote! { #priority_lit } + } else { + quote! { 100 } + }; + + // Generate description method + let description = if let Some(ref desc_lit) = self.description { + quote! { Some(#desc_lit.to_string()) } + } else { + quote! { None } + }; + + // Generate prefix method + let prefix = if let Some(ref prefix_lit) = self.prefix { + quote! { Some(#prefix_lit.to_string()) } + } else { + quote! { None } + }; + + // Generate by_prefix_only method + let by_prefix_only = if let Some(value) = self.by_prefix_only { + quote! { #value } + } else { + quote! { false } + }; + + // Generate init method + let init_method = if let Some(ref init_fn) = self.init_fn { + quote! { + fn init(&self) { + #init_fn(self); + } + } + } else { + quote! { + fn init(&self) { + // Default empty init + } + } + }; + + // Generate default_list method + let default_list_method = if let Some(ref default_list_fn) = self.default_list_fn { + quote! { + fn default_list(&self) -> Vec> { + #default_list_fn(self) + } + } + } else { + quote! { + fn default_list(&self) -> Vec> { + Vec::new() + } + } + }; + + // Generate filter method + let filter_method = if let Some(ref filter_fn) = self.filter_fn { + quote! { + fn filter(&self, query: &str) -> Vec> { + #filter_fn(self, query) + } + } + } else { + quote! { + fn filter(&self, query: &str) -> Vec> { + Vec::new() + } + } + }; + + quote! { + impl waycast_core::LauncherPlugin for #plugin_struct_name { + #init_method + + fn name(&self) -> String { + #name_str.to_string() + } + + fn priority(&self) -> i32 { + #priority + } + + fn description(&self) -> Option { + #description + } + + fn prefix(&self) -> Option { + #prefix + } + + fn by_prefix_only(&self) -> bool { + #by_prefix_only + } + + #default_list_method + + #filter_method + } + } + } + + /// Generate the complete plugin code + fn generate(&self) -> proc_macro2::TokenStream { + let plugin_struct_name = self.plugin_struct_name(); + let plugin_impl = self.generate_plugin_impl(); + + quote! { + pub struct #plugin_struct_name {} + + impl #plugin_struct_name { + pub fn new() -> Self { + #plugin_struct_name {} + } + } + + #plugin_impl + } + } +} + +/// The main plugin! proc macro +/// +/// Usage: +/// ```rust +/// plugin! { +/// struct Calculator; +/// name: "calculator", +/// priority: 500, +/// prefix: "calc", +/// filter: calc_filter, +/// } +/// ``` +#[proc_macro] +pub fn plugin(input: TokenStream) -> TokenStream { + let config = parse_macro_input!(input as PluginConfig); + config.generate().into() +} \ No newline at end of file diff --git a/waycast-plugins/Cargo.toml b/waycast-plugins/Cargo.toml index 40584c9..3e17af6 100644 --- a/waycast-plugins/Cargo.toml +++ b/waycast-plugins/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] waycast-core = { path = "../waycast-core" } +waycast-macros = { path = "../waycast-macros" } directories = "6.0.0" gio = "0.21.1" glib = "0.21.1" diff --git a/waycast-plugins/src/drun.rs b/waycast-plugins/src/drun.rs index 7ee2753..b00dd90 100644 --- a/waycast-plugins/src/drun.rs +++ b/waycast-plugins/src/drun.rs @@ -1,5 +1,6 @@ use gio::{prelude::*, AppInfo, DesktopAppInfo, Icon}; -use waycast_core::{LaunchError, LauncherListItem, LauncherPlugin}; +use waycast_core::{LaunchError, LauncherListItem}; +use crate::plugin; #[derive(Debug)] pub struct DesktopEntry { @@ -85,63 +86,43 @@ pub fn get_desktop_entries() -> Vec { entries } +fn drun_default_list(_plugin: &DrunPlugin) -> Vec> { + let mut entries: Vec> = Vec::new(); + + for e in get_desktop_entries() { + entries.push(Box::new(e)); + } + + entries +} + +fn drun_filter(plugin: &DrunPlugin, query: &str) -> Vec> { + if query.is_empty() { + return drun_default_list(plugin); + } + + let query_lower = query.to_lowercase(); + let mut entries: Vec> = Vec::new(); + for entry in drun_default_list(plugin) { + let title_lower = entry.title().to_lowercase(); + if title_lower.contains(&query_lower) { + entries.push(entry); + } + } + + entries +} + +plugin! { + struct Drun; + name: "drun", + priority: 1000, + description: "List and launch an installed application", + prefix: "app", + default_list: drun_default_list, + filter: drun_filter, +} + pub fn new() -> DrunPlugin { - DrunPlugin {} -} - -pub struct DrunPlugin {} - -impl LauncherPlugin for DrunPlugin { - fn init(&self) { - // TODO: Load apps into memory - // TODO: Find and cache Icons - } - fn name(&self) -> String { - return String::from("drun"); - } - - fn priority(&self) -> i32 { - return 1000; - } - - fn description(&self) -> Option { - return Some(String::from("List and launch an installed application")); - } - - // Prefix to isolate results to only use this plugin - fn prefix(&self) -> Option { - return Some(String::from("app")); - } - // Only search/use this plugin if the prefix was typed - fn by_prefix_only(&self) -> bool { - return false; - } - - // Actual item searching functions - fn default_list(&self) -> Vec> { - let mut entries: Vec> = Vec::new(); - - for e in get_desktop_entries() { - entries.push(Box::new(e)); - } - - entries - } - - fn filter(&self, query: &str) -> Vec> { - if query.is_empty() { - return self.default_list(); - } - - let query_lower = query.to_lowercase(); - let mut entries: Vec> = Vec::new(); - for entry in self.default_list() { - let title_lower = entry.title().to_lowercase(); - if title_lower.contains(&query_lower) { - entries.push(entry); - } - } - - entries - } + DrunPlugin::new() } diff --git a/waycast-plugins/src/file_search.rs b/waycast-plugins/src/file_search.rs index a4c8a97..f6e1231 100644 --- a/waycast-plugins/src/file_search.rs +++ b/waycast-plugins/src/file_search.rs @@ -7,8 +7,9 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use walkdir::{DirEntry, WalkDir}; +use waycast_macros::plugin; -use waycast_core::{LaunchError, LauncherListItem, LauncherPlugin}; +use waycast_core::{LaunchError, LauncherListItem}; #[derive(Clone)] struct FileEntry { @@ -103,27 +104,16 @@ pub fn default_search_list() -> Vec { Vec::new() } -pub fn new() -> FileSearchPlugin { - return FileSearchPlugin { - search_paths: default_search_list(), - skip_dirs: vec![ - String::from("vendor"), - String::from("node_modules"), - String::from("cache"), - String::from("zig-cache"), - ], - files: Arc::new(Mutex::new(Vec::new())), - }; -} +// Global state for file search +static mut FILE_SEARCH_DATA: Option = None; -pub struct FileSearchPlugin { +struct FileSearchData { search_paths: Vec, skip_dirs: Vec, - // Running list of files in memory files: Arc>>, } -impl FileSearchPlugin { +impl FileSearchData { pub fn add_search_path>(&mut self, path: P) -> Result<(), String> { let p = path.as_ref(); @@ -180,6 +170,43 @@ impl FileSearchPlugin { } } +fn get_file_search_data() -> &'static FileSearchData { + unsafe { + FILE_SEARCH_DATA.as_ref().unwrap() + } +} + +fn get_file_search_data_mut() -> &'static mut FileSearchData { + unsafe { + FILE_SEARCH_DATA.as_mut().unwrap() + } +} + +pub fn new() -> FileSearchPlugin { + unsafe { + FILE_SEARCH_DATA = Some(FileSearchData { + search_paths: default_search_list(), + skip_dirs: vec![ + String::from("vendor"), + String::from("node_modules"), + String::from("cache"), + String::from("zig-cache"), + ], + files: Arc::new(Mutex::new(Vec::new())), + }); + } + + FileSearchPlugin::new() +} + +pub fn add_search_path>(path: P) -> Result<(), String> { + get_file_search_data_mut().add_search_path(path) +} + +pub fn add_skip_dir(directory_name: String) -> Result<(), String> { + get_file_search_data_mut().add_skip_dir(directory_name) +} + fn skip_hidden(entry: &DirEntry) -> bool { entry .file_name() @@ -196,67 +223,58 @@ fn skip_dir(entry: &DirEntry, dirs: &Vec) -> bool { .unwrap_or(false) } -impl LauncherPlugin for FileSearchPlugin { - fn init(&self) { - // Start async file scanning with 500ms timeout - let self_clone = FileSearchPlugin { - search_paths: self.search_paths.clone(), - skip_dirs: self.skip_dirs.clone(), - files: Arc::clone(&self.files), - }; +fn file_search_default_list(_plugin: &FileSearchPlugin) -> Vec> { + Vec::new() +} - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - self_clone - .init_with_timeout(Duration::from_millis(2000)) - .await; - }); - }); - } - fn name(&self) -> String { - return String::from("File search"); +fn file_search_filter(_plugin: &FileSearchPlugin, query: &str) -> Vec> { + if query.is_empty() { + return file_search_default_list(_plugin); } - fn priority(&self) -> i32 { - return 900; - } + let mut entries: Vec> = Vec::new(); + let data = get_file_search_data(); - fn description(&self) -> Option { - None - } - - fn prefix(&self) -> Option { - Some(String::from("f")) - } - - fn by_prefix_only(&self) -> bool { - false - } - - fn default_list(&self) -> Vec> { - Vec::new() - } - - fn filter(&self, query: &str) -> Vec> { - if query.is_empty() { - return self.default_list(); - } - - let mut entries: Vec> = Vec::new(); - - // Try to get files without blocking - if indexing is still in progress, return empty - if let Ok(files) = self.files.try_lock() { - for f in files.iter() { - if let Some(file_name) = f.path.file_name() { - let cmp = file_name.to_string_lossy().to_lowercase(); - if cmp.contains(&query.to_lowercase()) { - entries.push(Box::new(f.clone())); - } + // Try to get files without blocking - if indexing is still in progress, return empty + if let Ok(files) = data.files.try_lock() { + for f in files.iter() { + if let Some(file_name) = f.path.file_name() { + let cmp = file_name.to_string_lossy().to_lowercase(); + if cmp.contains(&query.to_lowercase()) { + entries.push(Box::new(f.clone())); } } } - - entries } + + entries +} + +fn file_search_init(_plugin: &FileSearchPlugin) { + let data = get_file_search_data(); + let data_clone = FileSearchData { + search_paths: data.search_paths.clone(), + skip_dirs: data.skip_dirs.clone(), + files: Arc::clone(&data.files), + }; + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + data_clone + .init_with_timeout(Duration::from_millis(2000)) + .await; + }); + }); +} + +plugin! { + struct FileSearch; + name: "Files", + priority: 500, + description: "Search and open files", + prefix: "f", + init: file_search_init, + default_list: file_search_default_list, + filter: file_search_filter } diff --git a/waycast-plugins/src/lib.rs b/waycast-plugins/src/lib.rs index 61b1cd6..3ebcf4c 100644 --- a/waycast-plugins/src/lib.rs +++ b/waycast-plugins/src/lib.rs @@ -1,2 +1,5 @@ pub mod drun; pub mod file_search; + +// Re-export the macro for external use +pub use waycast_macros::plugin;