Reducing hella boilerplate with these macros. Thanks Claude

This commit is contained in:
Javier Feliz 2025-09-05 21:28:24 -04:00
parent 10c59c5412
commit e54cb62aa7
4 changed files with 240 additions and 94 deletions

View File

@ -254,3 +254,166 @@ pub fn plugin(input: TokenStream) -> TokenStream {
let config = parse_macro_input!(input as PluginConfig); let config = parse_macro_input!(input as PluginConfig);
config.generate().into() config.generate().into()
} }
/// LauncherListItem configuration parsed from the macro input
struct LauncherEntryConfig {
id: Expr,
title: Expr,
description: Option<Expr>,
icon: Expr,
execute: Expr,
}
impl Parse for LauncherEntryConfig {
fn parse(input: ParseStream) -> Result<Self> {
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::<Token![,]>()?;
}
if input.is_empty() {
break;
}
let key: Ident = input.parse()?;
input.parse::<Token![:]>()?;
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<String> {
#desc_expr
}
}
} else {
quote! {
fn description(&self) -> Option<String> {
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<String> { 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()
}

View File

@ -1,6 +1,6 @@
use gio::{prelude::*, AppInfo, DesktopAppInfo, Icon}; use gio::{prelude::*, AppInfo, DesktopAppInfo, Icon};
use waycast_core::{LaunchError, LauncherListItem, LauncherPlugin}; use waycast_core::{LaunchError, LauncherListItem, LauncherPlugin};
use waycast_macros::plugin; use waycast_macros::{plugin, launcher_entry};
#[derive(Debug)] #[derive(Debug)]
pub struct DesktopEntry { pub struct DesktopEntry {
@ -11,36 +11,17 @@ pub struct DesktopEntry {
} }
impl LauncherListItem for DesktopEntry { impl LauncherListItem for DesktopEntry {
fn id(&self) -> String { launcher_entry! {
self.id.clone() id: self.id.clone(),
} title: self.name.to_owned(),
description: {
fn title(&self) -> String {
return self.name.to_owned();
}
fn description(&self) -> Option<String> {
if let Some(glib_string) = &self.description { if let Some(glib_string) = &self.description {
return Some(glib_string.to_string().to_owned()); Some(glib_string.to_string().to_owned())
} else {
None
} }
},
return None; icon: {
}
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 Some(icon) = &self.icon {
if let Ok(ti) = icon.clone().downcast::<gio::ThemedIcon>() { if let Ok(ti) = icon.clone().downcast::<gio::ThemedIcon>() {
// ThemedIcon may have multiple names, we take the first // ThemedIcon may have multiple names, we take the first
@ -55,8 +36,20 @@ impl LauncherListItem for DesktopEntry {
} }
} }
} }
"application-x-executable".into()
return "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()))
}
}
} }
} }

View File

@ -7,7 +7,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use walkdir::{DirEntry, WalkDir}; use walkdir::{DirEntry, WalkDir};
use waycast_macros::plugin; use waycast_macros::{plugin, launcher_entry};
use waycast_core::{LaunchError, LauncherListItem, LauncherPlugin}; use waycast_core::{LaunchError, LauncherListItem, LauncherPlugin};
@ -25,18 +25,21 @@ impl FileEntry {
} }
impl LauncherListItem for FileEntry { impl LauncherListItem for FileEntry {
fn id(&self) -> String { launcher_entry! {
self.path.to_string_lossy().to_string() 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::<gio::ThemedIcon>() {
if let Some(icon_name) = themed_icon.names().first() {
return icon_name.to_string();
} }
fn title(&self) -> String {
return String::from(self.path.file_name().unwrap().to_string_lossy());
} }
fn description(&self) -> Option<String> { String::from("text-x-generic")
Some(self.path.to_string_lossy().to_string()) },
} execute: {
fn execute(&self) -> Result<(), LaunchError> {
println!("Executing: {}", self.path.display()); println!("Executing: {}", self.path.display());
// Use xdg-open directly since it works properly with music files // Use xdg-open directly since it works properly with music files
@ -66,19 +69,6 @@ impl LauncherListItem for FileEntry {
} }
} }
} }
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::<gio::ThemedIcon>() {
if let Some(icon_name) = themed_icon.names().first() {
return icon_name.to_string();
}
}
String::from("text-x-generic")
} }
} }

View File

@ -1,5 +1,5 @@
pub mod drun; pub mod drun;
pub mod file_search; pub mod file_search;
// Re-export the macro for external use // Re-export the macros for external use
pub use waycast_macros::plugin; pub use waycast_macros::{plugin, launcher_entry};