Skip to main content

gpui_ui_kit_macros/
lib.rs

1//! Proc macros for gpui-ui-kit
2//!
3//! Provides derive macros to reduce boilerplate in component theme definitions.
4//! The primary macro is [`ComponentTheme`] which generates `Default` and `From<&Theme>`
5//! implementations for theme structs, reducing repetitive boilerplate code.
6//!
7//! # Quick Start
8//!
9//! ```ignore
10//! use gpui_ui_kit_macros::ComponentTheme;
11//!
12//! #[derive(Debug, Clone, ComponentTheme)]
13//! pub struct MyComponentTheme {
14//!     #[theme(default = 0x007acc, from = accent)]
15//!     pub primary_color: Rgba,
16//!
17//!     #[theme(default = 0xffffff, from = text_primary)]
18//!     pub text_color: Rgba,
19//! }
20//! ```
21//!
22//! This generates:
23//! - `impl Default for MyComponentTheme` using the hex `default` values
24//! - `impl From<&Theme> for MyComponentTheme` mapping from global theme fields
25//!
26//! # Crate Features
27//!
28//! This is a proc-macro crate. It must be used alongside the main `gpui-ui-kit`
29//! crate which re-exports the macro as `ComponentTheme`.
30
31use proc_macro::TokenStream;
32use quote::quote;
33use syn::punctuated::Punctuated;
34use syn::{Data, DeriveInput, Expr, Fields, Lit, Meta, Token, parse_macro_input};
35
36/// Derive macro for component themes.
37///
38/// Generates `Default` and `From<&Theme>` implementations for theme structs,
39/// allowing components to have fallback colors while also automatically adapting
40/// to the global theme.
41///
42/// # Requirements
43///
44/// - Only works on structs with named fields
45/// - Every field must have a `#[theme(...)]` attribute
46/// - Each field needs both a default value and a mapping from Theme
47///
48/// # Attribute Reference
49///
50/// ## For Color Fields (Rgba)
51///
52/// | Attribute | Description | Example |
53/// |-----------|-------------|---------|
54/// | `default = 0xRRGGBB` | RGB hex color for Default impl | `default = 0x007acc` |
55/// | `default = 0xRRGGBBAA` | RGBA hex color (with alpha) | `default = 0x007acc80` |
56/// | `from = field_name` | Direct mapping from Theme field | `from = accent` |
57/// | `from_expr = "expr"` | Custom expression (uses `theme` variable) | `from_expr = "with_alpha(theme.accent, 0.2)"` |
58///
59/// ## For Numeric Fields (f32, etc.)
60///
61/// | Attribute | Description | Example |
62/// |-----------|-------------|---------|
63/// | `default_f32 = value` | f32 literal for Default impl | `default_f32 = 0.5` |
64/// | `from_expr = "value"` | Expression for From impl | `from_expr = "0.5"` |
65///
66/// ## For Other Types (Option, nested themes, etc.)
67///
68/// | Attribute | Description | Example |
69/// |-----------|-------------|---------|
70/// | `default_expr = "expr"` | Arbitrary expression for Default | `default_expr = "None"` |
71/// | `from_expr = "expr"` | Arbitrary expression for From | `from_expr = "Some(theme.accent)"` |
72///
73/// # Available Theme Fields
74///
75/// The global `Theme` struct provides these fields for mapping:
76///
77/// **Backgrounds:** `background`, `surface`, `surface_hover`, `muted`, `transparent`, `overlay_bg`
78///
79/// **Text:** `text_primary`, `text_secondary`, `text_muted`, `text_on_accent`, `icon_on_accent`
80///
81/// **Accent:** `accent`, `accent_hover`, `accent_muted`
82///
83/// **Semantic:** `success`, `warning`, `error`, `info`
84///
85/// **Border:** `border`, `border_hover`
86///
87/// # Examples
88///
89/// ## Basic Color Theme
90///
91/// ```ignore
92/// #[derive(Debug, Clone, ComponentTheme)]
93/// pub struct ButtonTheme {
94///     #[theme(default = 0x007acc, from = accent)]
95///     pub background: Rgba,
96///
97///     #[theme(default = 0xffffff, from = text_primary)]
98///     pub text: Rgba,
99///
100///     #[theme(default = 0x3a3a3a, from = border)]
101///     pub border: Rgba,
102/// }
103/// ```
104///
105/// ## With Custom Expressions
106///
107/// ```ignore
108/// use crate::color_tokens::with_alpha;
109///
110/// #[derive(Debug, Clone, ComponentTheme)]
111/// pub struct TooltipTheme {
112///     #[theme(default = 0x2a2a2aff, from = surface)]
113///     pub background: Rgba,
114///
115///     // Use with_alpha helper for transparency
116///     #[theme(default = 0x007acc33, from_expr = "with_alpha(theme.accent, 0.2)")]
117///     pub highlight: Rgba,
118///
119///     // Derived from another theme field
120///     #[theme(default = 0x888888, from_expr = "darken(theme.text_secondary, 0.1)")]
121///     pub shadow: Rgba,
122/// }
123/// ```
124///
125/// ## With Non-Color Fields
126///
127/// ```ignore
128/// #[derive(Debug, Clone, ComponentTheme)]
129/// pub struct FadeTheme {
130///     #[theme(default = 0xffffff, from = text_primary)]
131///     pub color: Rgba,
132///
133///     #[theme(default_f32 = 0.5, from_expr = "0.5")]
134///     pub disabled_opacity: f32,
135///
136///     #[theme(default_expr = "None", from_expr = "None")]
137///     pub optional_accent: Option<Rgba>,
138/// }
139/// ```
140///
141/// # Generated Code
142///
143/// For a theme struct `MyTheme`, this macro generates:
144///
145/// ```ignore
146/// impl Default for MyTheme {
147///     fn default() -> Self {
148///         Self {
149///             // Fields initialized with default values
150///         }
151///     }
152/// }
153///
154/// impl From<&crate::theme::Theme> for MyTheme {
155///     fn from(theme: &crate::theme::Theme) -> Self {
156///         Self {
157///             // Fields mapped from global theme
158///         }
159///     }
160/// }
161/// ```
162///
163/// # Common Patterns
164///
165/// ## Creating a theme from global state
166///
167/// ```ignore
168/// fn render(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
169///     let global_theme = cx.theme();
170///     let button_theme = ButtonTheme::from(&global_theme);
171///     // or use the default
172///     let default_theme = ButtonTheme::default();
173/// }
174/// ```
175///
176/// ## Customizing specific fields
177///
178/// ```ignore
179/// let mut theme = ButtonTheme::from(&cx.theme());
180/// theme.background = rgb(0xff0000); // Override just the background
181/// ```
182///
183/// # Compile Errors
184///
185/// The macro will panic at compile time if:
186/// - A field is missing the `#[theme(...)]` attribute
187/// - A field is missing `default`, `default_f32`, or `default_expr`
188/// - A field is missing `from` or `from_expr`
189/// - An expression in `from_expr` or `default_expr` fails to parse
190#[proc_macro_derive(ComponentTheme, attributes(theme))]
191pub fn derive_component_theme(input: TokenStream) -> TokenStream {
192    let input = parse_macro_input!(input as DeriveInput);
193    let name = &input.ident;
194
195    let fields = match &input.data {
196        Data::Struct(data) => match &data.fields {
197            Fields::Named(fields) => &fields.named,
198            _ => panic!("ComponentTheme only supports structs with named fields"),
199        },
200        _ => panic!("ComponentTheme only supports structs"),
201    };
202
203    let mut default_fields = Vec::new();
204    let mut from_fields = Vec::new();
205
206    for field in fields {
207        let field_name = field.ident.as_ref().unwrap();
208
209        // Find the #[theme(...)] attribute
210        let theme_attr = field
211            .attrs
212            .iter()
213            .find(|attr| attr.path().is_ident("theme"));
214
215        let Some(attr) = theme_attr else {
216            panic!("Field `{}` is missing #[theme(...)] attribute", field_name);
217        };
218
219        let mut default_value: Option<u32> = None;
220        let mut default_f32: Option<f64> = None;
221        let mut default_expr_str: Option<String> = None;
222        let mut from_field: Option<syn::Ident> = None;
223        let mut from_expr: Option<String> = None;
224
225        // Parse the attribute arguments
226        let nested = attr
227            .parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)
228            .expect("Failed to parse theme attribute");
229
230        for meta in nested {
231            match meta {
232                Meta::NameValue(nv) => {
233                    let ident = nv.path.get_ident().expect("Expected identifier");
234                    match ident.to_string().as_str() {
235                        "default" => {
236                            if let Expr::Lit(lit) = &nv.value
237                                && let Lit::Int(int_lit) = &lit.lit
238                            {
239                                default_value = Some(int_lit.base10_parse().unwrap());
240                            }
241                        }
242                        "default_f32" => {
243                            if let Expr::Lit(lit) = &nv.value {
244                                match &lit.lit {
245                                    Lit::Float(f) => {
246                                        default_f32 = Some(f.base10_parse().unwrap());
247                                    }
248                                    Lit::Int(i) => {
249                                        // Allow integers like 0 or 1
250                                        default_f32 = Some(i.base10_parse::<i64>().unwrap() as f64);
251                                    }
252                                    _ => {}
253                                }
254                            }
255                        }
256                        "default_expr" => {
257                            if let Expr::Lit(lit) = &nv.value
258                                && let Lit::Str(s) = &lit.lit
259                            {
260                                default_expr_str = Some(s.value());
261                            }
262                        }
263                        "from" => {
264                            if let Expr::Path(path) = &nv.value {
265                                from_field = path.path.get_ident().cloned();
266                            }
267                        }
268                        "from_expr" => {
269                            if let Expr::Lit(lit) = &nv.value
270                                && let Lit::Str(s) = &lit.lit
271                            {
272                                from_expr = Some(s.value());
273                            }
274                        }
275                        _ => panic!("Unknown theme attribute: {}", ident),
276                    }
277                }
278                _ => panic!("Expected name = value in theme attribute"),
279            }
280        }
281
282        // Generate Default field based on type
283        if let Some(expr_str) = default_expr_str {
284            // Arbitrary expression (for Option types, nested themes, etc.)
285            let expr: syn::Expr = syn::parse_str(&expr_str).unwrap_or_else(|_| {
286                panic!("Failed to parse default_expr for field `{}`", field_name)
287            });
288            default_fields.push(quote! {
289                #field_name: #expr
290            });
291        } else if let Some(f32_val) = default_f32 {
292            // f32 field
293            default_fields.push(quote! {
294                #field_name: #f32_val as f32
295            });
296        } else if let Some(default_val) = default_value {
297            // Check if it's RGB (6 hex digits) or RGBA (8 hex digits)
298            let default_expr = if default_val > 0xFFFFFF {
299                // RGBA - use rgba()
300                quote! { gpui::rgba(#default_val) }
301            } else {
302                // RGB - use rgb()
303                quote! { gpui::rgb(#default_val) }
304            };
305
306            default_fields.push(quote! {
307                #field_name: #default_expr
308            });
309        } else {
310            panic!(
311                "Field `{}` is missing `default`, `default_f32`, or `default_expr` in #[theme(...)]",
312                field_name
313            );
314        }
315
316        // Generate From<&Theme> field
317        if let Some(expr_str) = from_expr {
318            let expr: syn::Expr = syn::parse_str(&expr_str)
319                .unwrap_or_else(|_| panic!("Failed to parse from_expr for field `{}`", field_name));
320            from_fields.push(quote! {
321                #field_name: #expr
322            });
323        } else if let Some(from) = from_field {
324            from_fields.push(quote! {
325                #field_name: theme.#from
326            });
327        } else {
328            panic!(
329                "Field `{}` needs either `from` or `from_expr` in #[theme(...)]",
330                field_name
331            );
332        }
333    }
334
335    let expanded = quote! {
336        impl Default for #name {
337            fn default() -> Self {
338                Self {
339                    #(#default_fields),*
340                }
341            }
342        }
343
344        impl From<&crate::theme::Theme> for #name {
345            fn from(theme: &crate::theme::Theme) -> Self {
346                Self {
347                    #(#from_fields),*
348                }
349            }
350        }
351    };
352
353    TokenStream::from(expanded)
354}