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

@ -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<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 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::<gio::ThemedIcon>() {
// 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<String> {
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::<gio::ThemedIcon>() {
// 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::<gio::FileIcon>() {
if let Some(path) = fi.file().path() {
return path.to_string_lossy().to_string();
}
}
}
if let Ok(fi) = icon.clone().downcast::<gio::FileIcon>() {
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();
}
}

View File

@ -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<String> {
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::<gio::ThemedIcon>() {
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::<gio::ThemedIcon>() {
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<PathBuf> {

View File

@ -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};