diff --git a/waycast-macros/src/lib.rs b/waycast-macros/src/lib.rs index e1c062e..9f8a61d 100644 --- a/waycast-macros/src/lib.rs +++ b/waycast-macros/src/lib.rs @@ -253,4 +253,167 @@ impl PluginConfig { pub fn plugin(input: TokenStream) -> TokenStream { let config = parse_macro_input!(input as PluginConfig); config.generate().into() +} + +/// LauncherListItem configuration parsed from the macro input +struct LauncherEntryConfig { + id: Expr, + title: Expr, + description: Option, + icon: Expr, + execute: Expr, +} + +impl Parse for LauncherEntryConfig { + fn parse(input: ParseStream) -> Result { + let mut id = None; + let mut title = None; + let mut description = None; + let mut icon = None; + let mut execute = 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() { + "id" => { + let expr: Expr = input.parse()?; + id = Some(expr); + } + "title" => { + let expr: Expr = input.parse()?; + title = Some(expr); + } + "description" => { + let expr: Expr = input.parse()?; + description = Some(expr); + } + "icon" => { + let expr: Expr = input.parse()?; + icon = Some(expr); + } + "execute" => { + let expr: Expr = input.parse()?; + execute = Some(expr); + } + _ => { + let key_str = key.to_string(); + return Err(syn::Error::new_spanned( + key, + format!("Unknown launcher entry configuration key: {}", key_str), + )); + } + } + } + + // Validate required fields + let id = id.ok_or_else(|| { + syn::Error::new(input.span(), "LauncherListItem must have an 'id' field") + })?; + let title = title.ok_or_else(|| { + syn::Error::new(input.span(), "LauncherListItem must have a 'title' field") + })?; + let icon = icon.ok_or_else(|| { + syn::Error::new(input.span(), "LauncherListItem must have an 'icon' field") + })?; + let execute = execute.ok_or_else(|| { + syn::Error::new(input.span(), "LauncherListItem must have an 'execute' field") + })?; + + Ok(LauncherEntryConfig { + id, + title, + description, + icon, + execute, + }) + } +} + +impl LauncherEntryConfig { + /// Generate the implementation of LauncherListItem trait methods + fn generate(&self) -> proc_macro2::TokenStream { + let id_expr = &self.id; + let title_expr = &self.title; + let icon_expr = &self.icon; + let execute_expr = &self.execute; + + // Generate description method + let description_method = if let Some(ref desc_expr) = self.description { + quote! { + fn description(&self) -> Option { + #desc_expr + } + } + } else { + quote! { + fn description(&self) -> Option { + None + } + } + }; + + quote! { + fn id(&self) -> String { + #id_expr + } + + fn title(&self) -> String { + #title_expr + } + + #description_method + + fn icon(&self) -> String { + #icon_expr + } + + fn execute(&self) -> Result<(), waycast_core::LaunchError> { + #execute_expr + } + + // Stub implementations to satisfy rust-analyzer + // These are only compiled when rust-analyzer is checking the code + #[cfg(rust_analyzer)] + fn id(&self) -> String { unimplemented!("Generated by launcher_entry! macro") } + #[cfg(rust_analyzer)] + fn title(&self) -> String { unimplemented!("Generated by launcher_entry! macro") } + #[cfg(rust_analyzer)] + fn description(&self) -> Option { unimplemented!("Generated by launcher_entry! macro") } + #[cfg(rust_analyzer)] + fn icon(&self) -> String { unimplemented!("Generated by launcher_entry! macro") } + #[cfg(rust_analyzer)] + fn execute(&self) -> Result<(), waycast_core::LaunchError> { unimplemented!("Generated by launcher_entry! macro") } + } + } +} + +/// The launcher_entry! proc macro +/// +/// Usage inside impl LauncherListItem block: +/// ```rust +/// impl LauncherListItem for FileEntry { +/// launcher_entry! { +/// id: self.path.to_string_lossy().to_string(), +/// title: self.path.file_name().unwrap().to_string_lossy().to_string(), +/// description: Some(self.path.to_string_lossy().to_string()), +/// icon: "text-x-generic".to_string(), +/// execute: { println!("Opening file"); Ok(()) } +/// } +/// } +/// ``` +#[proc_macro] +pub fn launcher_entry(input: TokenStream) -> TokenStream { + let config = parse_macro_input!(input as LauncherEntryConfig); + config.generate().into() } \ No newline at end of file diff --git a/waycast-plugins/src/drun.rs b/waycast-plugins/src/drun.rs index 1c71319..a85e9d1 100644 --- a/waycast-plugins/src/drun.rs +++ b/waycast-plugins/src/drun.rs @@ -1,6 +1,6 @@ use gio::{prelude::*, AppInfo, DesktopAppInfo, Icon}; use waycast_core::{LaunchError, LauncherListItem, LauncherPlugin}; -use waycast_macros::plugin; +use waycast_macros::{plugin, launcher_entry}; #[derive(Debug)] pub struct DesktopEntry { @@ -11,52 +11,45 @@ pub struct DesktopEntry { } impl LauncherListItem for DesktopEntry { - fn id(&self) -> String { - self.id.clone() - } + launcher_entry! { + id: self.id.clone(), + title: self.name.to_owned(), + description: { + if let Some(glib_string) = &self.description { + Some(glib_string.to_string().to_owned()) + } else { + None + } + }, + icon: { + if let Some(icon) = &self.icon { + if let Ok(ti) = icon.clone().downcast::() { + // ThemedIcon may have multiple names, we take the first + if let Some(name) = ti.names().first() { + return name.to_string(); + } + } - fn title(&self) -> String { - return self.name.to_owned(); - } - - fn description(&self) -> Option { - if let Some(glib_string) = &self.description { - return Some(glib_string.to_string().to_owned()); - } - - return None; - } - - fn execute(&self) -> Result<(), LaunchError> { - if let Some(di) = DesktopAppInfo::new(&self.id) { - let app: AppInfo = di.upcast(); - let ctx = gio::AppLaunchContext::new(); - if app.launch(&[], Some(&ctx)).ok().is_none() { - return Err(LaunchError::CouldNotLaunch("App failed to launch".into())); - }; - return Ok(()); - } - - return Err(LaunchError::CouldNotLaunch("Invalid .desktop entry".into())); - } - - fn icon(&self) -> String { - if let Some(icon) = &self.icon { - if let Ok(ti) = icon.clone().downcast::() { - // ThemedIcon may have multiple names, we take the first - if let Some(name) = ti.names().first() { - return name.to_string(); + if let Ok(fi) = icon.clone().downcast::() { + if let Some(path) = fi.file().path() { + return path.to_string_lossy().to_string(); + } } } - - if let Ok(fi) = icon.clone().downcast::() { - if let Some(path) = fi.file().path() { - return path.to_string_lossy().to_string(); - } + "application-x-executable".into() + }, + execute: { + if let Some(di) = DesktopAppInfo::new(&self.id) { + let app: AppInfo = di.upcast(); + let ctx = gio::AppLaunchContext::new(); + if app.launch(&[], Some(&ctx)).ok().is_none() { + return Err(LaunchError::CouldNotLaunch("App failed to launch".into())); + }; + Ok(()) + } else { + Err(LaunchError::CouldNotLaunch("Invalid .desktop entry".into())) } } - - return "application-x-executable".into(); } } diff --git a/waycast-plugins/src/file_search.rs b/waycast-plugins/src/file_search.rs index db7e4b3..958df08 100644 --- a/waycast-plugins/src/file_search.rs +++ b/waycast-plugins/src/file_search.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use walkdir::{DirEntry, WalkDir}; -use waycast_macros::plugin; +use waycast_macros::{plugin, launcher_entry}; use waycast_core::{LaunchError, LauncherListItem, LauncherPlugin}; @@ -25,61 +25,51 @@ impl FileEntry { } impl LauncherListItem for FileEntry { - fn id(&self) -> String { - self.path.to_string_lossy().to_string() - } - - fn title(&self) -> String { - return String::from(self.path.file_name().unwrap().to_string_lossy()); - } - fn description(&self) -> Option { - Some(self.path.to_string_lossy().to_string()) - } - - fn execute(&self) -> Result<(), LaunchError> { - println!("Executing: {}", self.path.display()); - - // Use xdg-open directly since it works properly with music files - match Command::new("xdg-open").arg(&self.path).spawn() { - Ok(_) => { - println!("Successfully launched with xdg-open"); - Ok(()) + launcher_entry! { + id: self.path.to_string_lossy().to_string(), + title: String::from(self.path.file_name().unwrap().to_string_lossy()), + description: Some(self.path.to_string_lossy().to_string()), + icon: { + let (content_type, _) = gio::content_type_guess(Some(&self.path), None); + let icon = gio::content_type_get_icon(&content_type); + if let Some(themed_icon) = icon.downcast_ref::() { + if let Some(icon_name) = themed_icon.names().first() { + return icon_name.to_string(); + } } - Err(e) => { - println!("xdg-open failed: {}", e); - // Fallback to GIO method - let file_gio = gio::File::for_path(&self.path); - let ctx = gio::AppLaunchContext::new(); - match gio::AppInfo::launch_default_for_uri(file_gio.uri().as_str(), Some(&ctx)) { - Ok(()) => { - println!("Successfully launched with GIO fallback"); - Ok(()) - } - Err(e2) => { - println!("GIO fallback also failed: {}", e2); - Err(LaunchError::CouldNotLaunch(format!( - "Both xdg-open and GIO failed: {} / {}", - e, e2 - ))) + String::from("text-x-generic") + }, + execute: { + println!("Executing: {}", self.path.display()); + + // Use xdg-open directly since it works properly with music files + match Command::new("xdg-open").arg(&self.path).spawn() { + Ok(_) => { + println!("Successfully launched with xdg-open"); + Ok(()) + } + Err(e) => { + println!("xdg-open failed: {}", e); + // Fallback to GIO method + let file_gio = gio::File::for_path(&self.path); + let ctx = gio::AppLaunchContext::new(); + match gio::AppInfo::launch_default_for_uri(file_gio.uri().as_str(), Some(&ctx)) { + Ok(()) => { + println!("Successfully launched with GIO fallback"); + Ok(()) + } + Err(e2) => { + println!("GIO fallback also failed: {}", e2); + Err(LaunchError::CouldNotLaunch(format!( + "Both xdg-open and GIO failed: {} / {}", + e, e2 + ))) + } } } } } } - - fn icon(&self) -> String { - let (content_type, _) = gio::content_type_guess(Some(&self.path), None); - - let icon = gio::content_type_get_icon(&content_type); - - if let Some(themed_icon) = icon.downcast_ref::() { - if let Some(icon_name) = themed_icon.names().first() { - return icon_name.to_string(); - } - } - - String::from("text-x-generic") - } } pub fn default_search_list() -> Vec { diff --git a/waycast-plugins/src/lib.rs b/waycast-plugins/src/lib.rs index 3ebcf4c..b0dda10 100644 --- a/waycast-plugins/src/lib.rs +++ b/waycast-plugins/src/lib.rs @@ -1,5 +1,5 @@ pub mod drun; pub mod file_search; -// Re-export the macro for external use -pub use waycast_macros::plugin; +// Re-export the macros for external use +pub use waycast_macros::{plugin, launcher_entry};