Skip to main content

gpui_preview_derive/
lib.rs

1//! Derive macro for [`gpui_preview::Previewable`].
2//!
3//! This crate provides `#[derive(Previewable)]` which generates prop editor
4//! metadata and automatic component registration via [`inventory`] for use
5//! with the `gpui-preview` app.
6//!
7//! You normally don't depend on this crate directly — it's re-exported from
8//! `gpui-preview`.
9//!
10//! ## Struct attributes
11//!
12//! | Attribute | Description |
13//! |-----------|-------------|
14//! | `#[preview(category = "...")]` | Sidebar grouping in the preview app |
15//! | `#[preview(no_register)]` | Skip automatic `inventory` registration |
16//!
17//! ## Field attributes
18//!
19//! | Attribute | Description |
20//! |-----------|-------------|
21//! | `#[preview(skip)]` | Exclude field from the prop editor |
22//! | `#[preview(slider(min = 0.0, max = 100.0))]` | Render as a slider control |
23
24use proc_macro::TokenStream;
25use quote::quote;
26use syn::{Data, DeriveInput, Expr, Fields, Lit, Meta, parse_macro_input};
27
28#[proc_macro_derive(Previewable, attributes(preview))]
29pub fn derive_previewable(input: TokenStream) -> TokenStream {
30    let input = parse_macro_input!(input as DeriveInput);
31
32    match &input.data {
33        Data::Struct(data) => derive_struct(&input, data),
34        Data::Enum(data) => derive_enum(&input, data),
35        Data::Union(_) => {
36            syn::Error::new_spanned(&input, "Previewable cannot be derived for unions")
37                .to_compile_error()
38                .into()
39        }
40    }
41}
42
43// ── Struct derive ──────────────────────────────────────────────────────
44
45fn derive_struct(input: &DeriveInput, data: &syn::DataStruct) -> TokenStream {
46    let name = &input.ident;
47    let name_str = name.to_string();
48
49    let no_register = has_preview_flag(&input.attrs, "no_register");
50
51    let category =
52        extract_preview_kv(&input.attrs, "category").unwrap_or_else(|| "Uncategorized".to_string());
53    let description = extract_doc_comment(&input.attrs);
54
55    let Fields::Named(fields) = &data.fields else {
56        return syn::Error::new_spanned(&input.ident, "Previewable requires named fields")
57            .to_compile_error()
58            .into();
59    };
60
61    let mut field_metas = Vec::new();
62    let mut get_arms = Vec::new();
63    let mut set_arms = Vec::new();
64
65    for field in &fields.named {
66        let field_ident = field.ident.as_ref().unwrap();
67        let field_name = field_ident.to_string();
68
69        if has_preview_flag(&field.attrs, "skip") {
70            continue;
71        }
72
73        let doc = extract_doc_comment(&field.attrs);
74        let ty = &field.ty;
75
76        // Control kind
77        let control = if let Some((min, max)) = extract_slider_attr(&field.attrs) {
78            quote! { gpui_preview::ControlKind::NumberSlider { min: #min, max: #max } }
79        } else {
80            type_to_control(ty)
81        };
82
83        field_metas.push(quote! {
84            gpui_preview::FieldMeta {
85                name: #field_name,
86                doc: #doc,
87                control: #control,
88            }
89        });
90
91        get_arms.push(type_to_get(field_ident, &field_name, ty));
92        set_arms.push(type_to_set(field_ident, &field_name, ty));
93    }
94
95    let registration = if no_register {
96        quote! {}
97    } else {
98        quote! {
99            gpui_preview::inventory::submit! {
100                gpui_preview::PreviewEntry {
101                    id: || std::any::type_name::<#name>(),
102                    name: #name_str,
103                    category: #category,
104                    description: #description,
105                    fields: <#name as gpui_preview::Previewable>::fields,
106                    create_default: || {
107                        Box::new(<#name as gpui_preview::Previewable>::default_preview())
108                    },
109                    render: |any: &dyn std::any::Any| -> gpui::AnyElement {
110                        let instance = any.downcast_ref::<#name>().expect("type mismatch in render");
111                        gpui::IntoElement::into_any_element(gpui::Component::new(instance.clone()))
112                    },
113                }
114            }
115        }
116    };
117
118    let expanded = quote! {
119        impl gpui_preview::Previewable for #name {
120            fn name() -> &'static str { #name_str }
121            fn category() -> &'static str { #category }
122            fn description() -> &'static str { #description }
123
124            fn default_preview() -> Self {
125                Default::default()
126            }
127
128            fn fields() -> Vec<gpui_preview::FieldMeta> {
129                vec![#(#field_metas),*]
130            }
131
132            fn get_field(&self, name: &str) -> Option<gpui_preview::FieldValue> {
133                match name {
134                    #(#get_arms)*
135                    _ => None,
136                }
137            }
138
139            fn set_field(&self, name: &str, value: gpui_preview::FieldValue) -> Self {
140                let mut new = self.clone();
141                match (name, value) {
142                    #(#set_arms)*
143                    _ => {}
144                }
145                new
146            }
147        }
148
149        #registration
150    };
151
152    expanded.into()
153}
154
155// ── Enum derive ────────────────────────────────────────────────────────
156
157fn derive_enum(input: &DeriveInput, data: &syn::DataEnum) -> TokenStream {
158    let name = &input.ident;
159
160    let mut variant_idents = Vec::new();
161    let mut variant_strs = Vec::new();
162
163    for variant in &data.variants {
164        if !variant.fields.is_empty() {
165            return syn::Error::new_spanned(
166                variant,
167                "Previewable enums must have only unit variants (no fields)",
168            )
169            .to_compile_error()
170            .into();
171        }
172        variant_idents.push(&variant.ident);
173        variant_strs.push(variant.ident.to_string());
174    }
175
176    let expanded = quote! {
177        impl gpui_preview::PreviewEnum for #name {
178            fn variants() -> &'static [&'static str] {
179                &[#(#variant_strs),*]
180            }
181
182            fn to_variant_name(&self) -> &'static str {
183                match self {
184                    #(Self::#variant_idents => #variant_strs,)*
185                }
186            }
187
188            fn from_variant_name(name: &str) -> Option<Self> {
189                match name {
190                    #(#variant_strs => Some(Self::#variant_idents),)*
191                    _ => None,
192                }
193            }
194        }
195    };
196
197    expanded.into()
198}
199
200// ── Type → ControlKind mapping ─────────────────────────────────────────
201
202fn type_to_control(ty: &syn::Type) -> proc_macro2::TokenStream {
203    if let Some(inner) = extract_option_inner(ty) {
204        let inner_control = type_to_control(inner);
205        return quote! { gpui_preview::ControlKind::Optional(Box::new(#inner_control)) };
206    }
207
208    let Some(type_name) = last_segment_name(ty) else {
209        return quote! { gpui_preview::ControlKind::Unsupported };
210    };
211
212    match type_name.as_str() {
213        "String" => quote! { gpui_preview::ControlKind::TextInput },
214        "bool" => quote! { gpui_preview::ControlKind::Toggle },
215        "f32" | "f64" => {
216            quote! { gpui_preview::ControlKind::NumberSlider { min: 0.0, max: 100.0 } }
217        }
218        "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "isize" | "usize" => {
219            quote! { gpui_preview::ControlKind::NumberSlider { min: 0.0, max: 100.0 } }
220        }
221        "Hsla" => quote! { gpui_preview::ControlKind::Color },
222        _ => {
223            // Assume it implements PreviewEnum
224            quote! {
225                gpui_preview::ControlKind::Select(
226                    <#ty as gpui_preview::PreviewEnum>::variants().to_vec()
227                )
228            }
229        }
230    }
231}
232
233// ── Type → get_field arm ───────────────────────────────────────────────
234
235fn type_to_get(
236    field_ident: &syn::Ident,
237    field_name: &str,
238    ty: &syn::Type,
239) -> proc_macro2::TokenStream {
240    if let Some(inner) = extract_option_inner(ty) {
241        let inner_get = type_to_get_expr(field_ident, inner, true);
242        return quote! {
243            #field_name => match &self.#field_ident {
244                Some(_opt_inner) => #inner_get,
245                None => Some(gpui_preview::FieldValue::None),
246            },
247        };
248    }
249
250    let Some(type_name) = last_segment_name(ty) else {
251        return quote! { #field_name => None, };
252    };
253
254    match type_name.as_str() {
255        "String" => quote! {
256            #field_name => Some(gpui_preview::FieldValue::String(self.#field_ident.clone())),
257        },
258        "bool" => quote! {
259            #field_name => Some(gpui_preview::FieldValue::Bool(self.#field_ident)),
260        },
261        "f32" | "f64" => quote! {
262            #field_name => Some(gpui_preview::FieldValue::Float(self.#field_ident as f64)),
263        },
264        "i8" | "i16" | "i32" | "i64" | "isize" | "u8" | "u16" | "u32" | "u64" | "usize" => {
265            quote! {
266                #field_name => Some(gpui_preview::FieldValue::Int(self.#field_ident as i64)),
267            }
268        }
269        "Hsla" => quote! {
270            #field_name => {
271                let rgba: gpui::Rgba = self.#field_ident.into();
272                Some(gpui_preview::FieldValue::Color([
273                    (rgba.r * 255.0) as u8,
274                    (rgba.g * 255.0) as u8,
275                    (rgba.b * 255.0) as u8,
276                    (rgba.a * 255.0) as u8,
277                ]))
278            },
279        },
280        _ => quote! {
281            #field_name => Some(gpui_preview::FieldValue::Enum(
282                gpui_preview::PreviewEnum::to_variant_name(&self.#field_ident).to_string()
283            )),
284        },
285    }
286}
287
288fn type_to_get_expr(
289    field_ident: &syn::Ident,
290    ty: &syn::Type,
291    is_option_inner: bool,
292) -> proc_macro2::TokenStream {
293    let src = if is_option_inner {
294        quote! { _opt_inner }
295    } else {
296        quote! { self.#field_ident }
297    };
298
299    let Some(type_name) = last_segment_name(ty) else {
300        return quote! { None };
301    };
302
303    match type_name.as_str() {
304        "String" => quote! { Some(gpui_preview::FieldValue::String(#src.clone())) },
305        "bool" => quote! { Some(gpui_preview::FieldValue::Bool(*#src)) },
306        "f32" | "f64" => quote! { Some(gpui_preview::FieldValue::Float(*#src as f64)) },
307        "i8" | "i16" | "i32" | "i64" | "isize" | "u8" | "u16" | "u32" | "u64" | "usize" => {
308            quote! { Some(gpui_preview::FieldValue::Int(*#src as i64)) }
309        }
310        "Hsla" => quote! {
311            {
312                let rgba: gpui::Rgba = (*#src).into();
313                Some(gpui_preview::FieldValue::Color([
314                    (rgba.r * 255.0) as u8,
315                    (rgba.g * 255.0) as u8,
316                    (rgba.b * 255.0) as u8,
317                    (rgba.a * 255.0) as u8,
318                ]))
319            }
320        },
321        _ => quote! {
322            Some(gpui_preview::FieldValue::Enum(
323                gpui_preview::PreviewEnum::to_variant_name(#src).to_string()
324            ))
325        },
326    }
327}
328
329// ── Type → set_field arm ───────────────────────────────────────────────
330
331fn type_to_set(
332    field_ident: &syn::Ident,
333    field_name: &str,
334    ty: &syn::Type,
335) -> proc_macro2::TokenStream {
336    if let Some(inner) = extract_option_inner(ty) {
337        let inner_set = type_to_set_expr(field_ident, inner, true);
338        return quote! {
339            (#field_name, gpui_preview::FieldValue::None) => { new.#field_ident = None; }
340            #inner_set
341        };
342    }
343
344    let Some(type_name) = last_segment_name(ty) else {
345        return quote! {};
346    };
347
348    match type_name.as_str() {
349        "String" => quote! {
350            (#field_name, gpui_preview::FieldValue::String(v)) => { new.#field_ident = v; }
351        },
352        "bool" => quote! {
353            (#field_name, gpui_preview::FieldValue::Bool(v)) => { new.#field_ident = v; }
354        },
355        "f32" => quote! {
356            (#field_name, gpui_preview::FieldValue::Float(v)) => { new.#field_ident = v as f32; }
357        },
358        "f64" => quote! {
359            (#field_name, gpui_preview::FieldValue::Float(v)) => { new.#field_ident = v; }
360        },
361        "i8" | "i16" | "i32" | "i64" | "isize" | "u8" | "u16" | "u32" | "u64" | "usize" => {
362            quote! {
363                (#field_name, gpui_preview::FieldValue::Int(v)) => { new.#field_ident = v as #ty; }
364            }
365        }
366        "Hsla" => quote! {
367            (#field_name, gpui_preview::FieldValue::Color(v)) => {
368                new.#field_ident = gpui::Hsla::from(gpui::Rgba {
369                    r: v[0] as f32 / 255.0,
370                    g: v[1] as f32 / 255.0,
371                    b: v[2] as f32 / 255.0,
372                    a: v[3] as f32 / 255.0,
373                });
374            }
375        },
376        _ => quote! {
377            (#field_name, gpui_preview::FieldValue::Enum(ref v)) => {
378                if let Some(e) = <#ty as gpui_preview::PreviewEnum>::from_variant_name(v) {
379                    new.#field_ident = e;
380                }
381            }
382        },
383    }
384}
385
386fn type_to_set_expr(
387    field_ident: &syn::Ident,
388    inner_ty: &syn::Type,
389    _is_option: bool,
390) -> proc_macro2::TokenStream {
391    let Some(type_name) = last_segment_name(inner_ty) else {
392        return quote! {};
393    };
394
395    let field_name = field_ident.to_string();
396
397    match type_name.as_str() {
398        "String" => quote! {
399            (#field_name, gpui_preview::FieldValue::String(v)) => { new.#field_ident = Some(v); }
400        },
401        "bool" => quote! {
402            (#field_name, gpui_preview::FieldValue::Bool(v)) => { new.#field_ident = Some(v); }
403        },
404        "f32" => quote! {
405            (#field_name, gpui_preview::FieldValue::Float(v)) => { new.#field_ident = Some(v as f32); }
406        },
407        "f64" => quote! {
408            (#field_name, gpui_preview::FieldValue::Float(v)) => { new.#field_ident = Some(v); }
409        },
410        "i8" | "i16" | "i32" | "i64" | "isize" | "u8" | "u16" | "u32" | "u64" | "usize" => {
411            quote! {
412                (#field_name, gpui_preview::FieldValue::Int(v)) => { new.#field_ident = Some(v as #inner_ty); }
413            }
414        }
415        "Hsla" => quote! {
416            (#field_name, gpui_preview::FieldValue::Color(v)) => {
417                new.#field_ident = Some(gpui::Hsla::from(gpui::Rgba {
418                    r: v[0] as f32 / 255.0,
419                    g: v[1] as f32 / 255.0,
420                    b: v[2] as f32 / 255.0,
421                    a: v[3] as f32 / 255.0,
422                }));
423            }
424        },
425        _ => quote! {
426            (#field_name, gpui_preview::FieldValue::Enum(ref v)) => {
427                if let Some(e) = <#inner_ty as gpui_preview::PreviewEnum>::from_variant_name(v) {
428                    new.#field_ident = Some(e);
429                }
430            }
431        },
432    }
433}
434
435// ── Attribute helpers ──────────────────────────────────────────────────
436
437fn extract_preview_kv(attrs: &[syn::Attribute], key: &str) -> Option<String> {
438    for attr in attrs {
439        if !attr.path().is_ident("preview") {
440            continue;
441        }
442        let Ok(nested) = attr
443            .parse_args_with(syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated)
444        else {
445            continue;
446        };
447        for meta in &nested {
448            if let Meta::NameValue(nv) = meta
449                && nv.path.is_ident(key)
450                && let Expr::Lit(expr_lit) = &nv.value
451                && let Lit::Str(lit_str) = &expr_lit.lit
452            {
453                return Some(lit_str.value());
454            }
455        }
456    }
457    None
458}
459
460fn has_preview_flag(attrs: &[syn::Attribute], flag: &str) -> bool {
461    for attr in attrs {
462        if !attr.path().is_ident("preview") {
463            continue;
464        }
465        let Ok(nested) = attr
466            .parse_args_with(syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated)
467        else {
468            continue;
469        };
470        for meta in &nested {
471            if let Meta::Path(path) = meta
472                && path.is_ident(flag)
473            {
474                return true;
475            }
476        }
477    }
478    false
479}
480
481fn extract_slider_attr(attrs: &[syn::Attribute]) -> Option<(f64, f64)> {
482    for attr in attrs {
483        if !attr.path().is_ident("preview") {
484            continue;
485        }
486        let Ok(nested) = attr
487            .parse_args_with(syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated)
488        else {
489            continue;
490        };
491        for meta in &nested {
492            if let Meta::List(list) = meta
493                && list.path.is_ident("slider")
494            {
495                let Ok(inner) = list.parse_args_with(
496                    syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
497                ) else {
498                    continue;
499                };
500                let mut min = 0.0f64;
501                let mut max = 100.0f64;
502                for m in &inner {
503                    if let Meta::NameValue(nv) = m
504                        && let Expr::Lit(expr_lit) = &nv.value
505                        && let Lit::Float(f) = &expr_lit.lit
506                    {
507                        let val: f64 = f.base10_parse().unwrap();
508                        if nv.path.is_ident("min") {
509                            min = val;
510                        } else if nv.path.is_ident("max") {
511                            max = val;
512                        }
513                    }
514                }
515                return Some((min, max));
516            }
517        }
518    }
519    None
520}
521
522fn extract_doc_comment(attrs: &[syn::Attribute]) -> String {
523    let mut lines = Vec::new();
524    for attr in attrs {
525        if !attr.path().is_ident("doc") {
526            continue;
527        }
528        if let Meta::NameValue(nv) = &attr.meta
529            && let Expr::Lit(expr_lit) = &nv.value
530            && let Lit::Str(lit_str) = &expr_lit.lit
531        {
532            lines.push(lit_str.value().trim().to_string());
533        }
534    }
535    lines.join(" ")
536}
537
538fn last_segment_name(ty: &syn::Type) -> Option<String> {
539    if let syn::Type::Path(type_path) = ty {
540        type_path.path.segments.last().map(|s| s.ident.to_string())
541    } else {
542        None
543    }
544}
545
546fn extract_option_inner(ty: &syn::Type) -> Option<&syn::Type> {
547    let syn::Type::Path(type_path) = ty else {
548        return None;
549    };
550    let segment = type_path.path.segments.last()?;
551    if segment.ident != "Option" {
552        return None;
553    }
554    let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
555        return None;
556    };
557    let syn::GenericArgument::Type(inner) = args.args.first()? else {
558        return None;
559    };
560    Some(inner)
561}