Skip to main content

ratatui_input_manager_derive/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3//! Macros for [ratatui-input-manager](https://crates.io/crates/ratatui-input-manager)
4
5use darling::{FromAttributes, FromMeta, ast::NestedMeta};
6use itertools::MultiUnzip;
7use proc_macro2::TokenStream;
8use quote::{ToTokens, quote};
9use syn::{
10    Expr, ExprLit, ImplItem, ItemImpl, Lit, Meta, MetaNameValue, parse_macro_input, parse_quote,
11    spanned::Spanned,
12};
13
14#[derive(FromMeta)]
15struct KeyMapAttrs {
16    #[darling(default)]
17    backend: Backend,
18}
19
20impl KeyMapAttrs {
21    fn parse_attrs(attrs: TokenStream) -> darling::Result<Self> {
22        KeyMapAttrs::from_list(&NestedMeta::parse_meta_list(attrs)?)
23    }
24}
25
26#[derive(Default, FromMeta)]
27enum Backend {
28    #[cfg_attr(all(feature = "crossterm",), default)]
29    Crossterm,
30    #[cfg_attr(all(not(feature = "crossterm"), feature = "termion",), default)]
31    Termion,
32    #[cfg_attr(
33        all(
34            not(feature = "crossterm"),
35            not(feature = "termion"),
36            feature = "termwiz"
37        ),
38        default
39    )]
40    Termwiz,
41}
42
43impl Backend {
44    fn backend_type(&self) -> Expr {
45        match self {
46            Self::Crossterm => parse_quote!(::ratatui_input_manager::CrosstermBackend),
47            Self::Termion => parse_quote!(::ratatui_input_manager::TermionBackend),
48            Self::Termwiz => parse_quote!(::ratatui_input_manager::TermwizBackend),
49        }
50    }
51
52    fn combine_modifiers(&self, modifiers: &[Expr]) -> Expr {
53        match self {
54            Self::Crossterm => {
55                parse_quote! {::crossterm::event::KeyModifiers::NONE #(.union(#modifiers))*}
56            }
57            Self::Termion => parse_quote! { () },
58            Self::Termwiz => {
59                parse_quote! { ::termwiz::input::Modifiers::NONE #(.union(#modifiers))* }
60            }
61        }
62    }
63
64    fn input_event(&self, key_code: &Expr, combined_modifiers: &Expr) -> TokenStream {
65        match self {
66            Self::Crossterm => {
67                quote! {
68                    ::crossterm::event::Event::Key(
69                        ::crossterm::event::KeyEvent {
70                            code: #key_code,
71                            modifiers,
72                            kind: ::crossterm::event::KeyEventKind::Press,
73                            ..
74                        }
75                    ) if modifiers.contains(#combined_modifiers)
76                }
77            }
78            Self::Termion => quote! {
79                ::termion::event::Event::Key(
80                    ::termion::event::#key_code
81                )
82            },
83            Self::Termwiz => {
84                quote! {
85                    ::termwiz::input::InputEvent::Key(
86                        ::termwiz::input::KeyEvent {
87                            key: #key_code,
88                            modifiers,
89                        }
90                    ) if modifiers.contains(#combined_modifiers)
91                }
92            }
93        }
94    }
95}
96
97#[derive(Debug, FromMeta)]
98struct Pressed {
99    key: syn::Expr,
100    #[darling(multiple)]
101    modifiers: Vec<syn::Expr>,
102}
103
104#[derive(FromAttributes)]
105#[darling(attributes(keybind), forward_attrs)]
106struct KeyBindAttrs {
107    #[darling(multiple)]
108    pressed: Vec<Pressed>,
109    attrs: Vec<syn::Attribute>,
110}
111
112/// Generate an implementation of [`ratatui_input_manager::KeyMap`], dispatching input events to
113/// the appropriate methods according to the attributes provided
114#[allow(rustdoc::broken_intra_doc_links)]
115#[proc_macro_attribute]
116pub fn keymap(
117    attrs: proc_macro::TokenStream,
118    input: proc_macro::TokenStream,
119) -> proc_macro::TokenStream {
120    let args = match KeyMapAttrs::parse_attrs(attrs.into()) {
121        Ok(args) => args,
122        Err(err) => return err.write_errors().into(),
123    };
124    let input = parse_macro_input!(input as ItemImpl);
125    match keymap_impl(args, input) {
126        Ok((original_impl, keymap_impl)) => TokenStream::from_iter([
127            original_impl.to_token_stream(),
128            keymap_impl.to_token_stream(),
129        ])
130        .into(),
131        Err(err) => err.into_compile_error().into(),
132    }
133}
134
135fn keymap_impl(args: KeyMapAttrs, input: ItemImpl) -> syn::Result<(ItemImpl, ItemImpl)> {
136    let ItemImpl { self_ty, items, .. } = input;
137
138    let (keybinds, orig_impls) = items
139        .into_iter()
140        .map(|item| match item {
141            ImplItem::Fn(mut item_fn) => {
142                let KeyBindAttrs { pressed, attrs } =
143                    KeyBindAttrs::from_attributes(&item_fn.attrs)?;
144                let doc = attrs
145                    .iter()
146                    .find_map(|attr| {
147                        if let Meta::NameValue(MetaNameValue { path, value, .. }) = &attr.meta
148                            && path.is_ident("doc")
149                            && let Expr::Lit(ExprLit {
150                                lit: Lit::Str(doc), ..
151                            }) = value
152                        {
153                            Some(doc.value().trim().to_string())
154                        } else {
155                            None
156                        }
157                    })
158                    .ok_or_else(|| {
159                        syn::Error::new(
160                            item_fn.sig.ident.span(),
161                            "Keybind functions must have a doc comment for the description",
162                        )
163                    })?;
164                item_fn.attrs = attrs;
165                Ok(((item_fn.sig.ident.clone(), pressed, doc), item_fn))
166            }
167            _ => Err(syn::Error::new(
168                item.span(),
169                "Only function definitions are permitted with a keymap",
170            )),
171        })
172        .collect::<Result<(Vec<_>, Vec<_>), _>>()?;
173
174    let orig_impl = parse_quote! {
175        impl #self_ty {
176            #(#orig_impls)*
177        }
178    };
179
180    let (fn_names, match_arms, key_codes, combined_modifiers, descriptions): (
181        Vec<_>,
182        Vec<Vec<_>>,
183        Vec<Vec<_>>,
184        Vec<Vec<_>>,
185        Vec<_>,
186    ) = keybinds
187        .into_iter()
188        .map(|(fn_name, pressed, description)| {
189            let (match_arm, key_codes, combined_modifiers) = pressed
190                .into_iter()
191                .map(|Pressed { key, modifiers }| {
192                    let combined_modifiers = args.backend.combine_modifiers(&modifiers);
193                    let match_arm = args.backend.input_event(&key, &combined_modifiers);
194                    (match_arm, key, combined_modifiers)
195                })
196                .multiunzip();
197            (
198                fn_name,
199                match_arm,
200                key_codes,
201                combined_modifiers,
202                description,
203            )
204        })
205        .multiunzip();
206
207    let backend_type = args.backend.backend_type();
208
209    let keymap_impl = parse_quote! {
210        impl ::ratatui_input_manager::KeyMap::<#backend_type> for #self_ty {
211            const KEYBINDS: &'static [::ratatui_input_manager::KeyBind::<#backend_type>] = &[
212                #(
213                    ::ratatui_input_manager::KeyBind::<#backend_type> {
214                        pressed: &[
215                            #(::ratatui_input_manager::KeyPress::<#backend_type> {
216                                key: #key_codes,
217                                modifiers: #combined_modifiers,
218                            }),*
219                        ],
220                        description: #descriptions,
221                    },
222                )*
223            ];
224
225            fn handle(&mut self, event: &<#backend_type as ::ratatui_input_manager::Backend>::Event) -> bool {
226                match event {
227                    #(#(#match_arms => {self.#fn_names(); true},)*)*
228                    _ => false
229                }
230            }
231        }
232    };
233
234    Ok((orig_impl, keymap_impl))
235}
236
237#[cfg(test)]
238mod tests {
239    use super::{Backend, KeyMapAttrs, keymap_impl};
240    use pretty_assertions::assert_eq;
241    use prettyplease::unparse;
242    use quote::quote;
243    use syn::{Item, ItemImpl, parse_quote, parse2};
244
245    fn format_item<I>(item: I) -> String
246    where
247        I: Into<Item>,
248    {
249        let file = syn::File {
250            attrs: vec![],
251            items: vec![item.into()],
252            shebang: None,
253        };
254        unparse(&file)
255    }
256
257    #[test]
258    fn test_generated_impl_crossterm() {
259        let args = KeyMapAttrs {
260            backend: Backend::Crossterm,
261        };
262        let input = parse_quote! {
263            impl Foo {
264                /// The first keybind
265                #[keybind(pressed(key=KeyCode::Esc))]
266                #[keybind(pressed(key=KeyCode::Char('q')))]
267                fn bar(&mut self) {
268                    todo!()
269                }
270
271                /// The second keybind
272                #[keybind(pressed(key=KeyCode::Char('a'), modifiers=KeyModifiers::CONTROL, modifiers=KeyModifiers::SHIFT))]
273                fn baz(&mut self) {
274                    todo!()
275                }
276            }
277        };
278        let (orig_impl, keymap_impl) = keymap_impl(args, input).unwrap();
279        let expected_orig = parse2::<ItemImpl>(quote! {
280            impl Foo {
281                /// The first keybind
282                fn bar(&mut self) {
283                    todo!()
284                }
285
286                /// The second keybind
287                fn baz(&mut self) {
288                    todo!()
289                }
290            }
291        })
292        .unwrap();
293        let expected_keymap: ItemImpl = parse_quote! {
294            impl ::ratatui_input_manager::KeyMap::<::ratatui_input_manager::CrosstermBackend> for Foo {
295                const KEYBINDS: &'static [::ratatui_input_manager::KeyBind::<::ratatui_input_manager::CrosstermBackend>] = &[
296                    ::ratatui_input_manager::KeyBind::<::ratatui_input_manager::CrosstermBackend> {
297                        pressed: &[
298                            ::ratatui_input_manager::KeyPress::<::ratatui_input_manager::CrosstermBackend> {
299                                key: KeyCode::Esc,
300                                modifiers: ::crossterm::event::KeyModifiers::NONE,
301                            },
302                            ::ratatui_input_manager::KeyPress::<::ratatui_input_manager::CrosstermBackend> {
303                                key: KeyCode::Char('q'),
304                                modifiers: ::crossterm::event::KeyModifiers::NONE,
305                            },
306                        ],
307                        description: "The first keybind",
308                    },
309                    ::ratatui_input_manager::KeyBind::<::ratatui_input_manager::CrosstermBackend> {
310                        pressed: &[
311                            ::ratatui_input_manager::KeyPress::<::ratatui_input_manager::CrosstermBackend> {
312                                key: KeyCode::Char('a'),
313                                modifiers: ::crossterm::event::KeyModifiers::NONE
314                                    .union(KeyModifiers::CONTROL)
315                                    .union(KeyModifiers::SHIFT),
316                            },
317                        ],
318                        description: "The second keybind",
319                    }
320                ];
321
322                fn handle(&mut self, event: &<::ratatui_input_manager::CrosstermBackend as ::ratatui_input_manager::Backend>::Event) -> bool {
323                    match event {
324                        ::crossterm::event::Event::Key(
325                            ::crossterm::event::KeyEvent {
326                                code: KeyCode::Esc,
327                                modifiers,
328                                kind: ::crossterm::event::KeyEventKind::Press,
329                                ..
330                            },
331                        ) if modifiers.contains(::crossterm::event::KeyModifiers::NONE) => { self.bar(); true }
332                        ::crossterm::event::Event::Key(
333                            ::crossterm::event::KeyEvent {
334                                code: KeyCode::Char('q'),
335                                modifiers,
336                                kind: ::crossterm::event::KeyEventKind::Press,
337                                ..
338                            },
339                        ) if modifiers.contains(::crossterm::event::KeyModifiers::NONE) => { self.bar(); true }
340                        ::crossterm::event::Event::Key(
341                            ::crossterm::event::KeyEvent {
342                                code: KeyCode::Char('a'),
343                                modifiers,
344                                kind: ::crossterm::event::KeyEventKind::Press,
345                                ..
346                            },
347                        ) if modifiers
348                            .contains(
349                                ::crossterm::event::KeyModifiers::NONE
350                                    .union(KeyModifiers::CONTROL)
351                                    .union(KeyModifiers::SHIFT),
352                            ) => { self.baz(); true }
353                        _ => false,
354                    }
355                }
356            }
357        };
358
359        assert_eq!(format_item(expected_orig), format_item(orig_impl));
360        assert_eq!(
361            format_item::<ItemImpl>(expected_keymap),
362            format_item(keymap_impl)
363        );
364    }
365
366    #[test]
367    fn test_generated_impl_termion() {
368        let args = KeyMapAttrs {
369            backend: Backend::Termion,
370        };
371        let input = parse_quote! {
372            impl Foo {
373                /// The first keybind
374                #[keybind(pressed(key=Key::Esc))]
375                #[keybind(pressed(key=Key::Char('q')))]
376                fn bar(&mut self) {
377                    todo!()
378                }
379
380                /// The second keybind
381                #[keybind(pressed(key=Key::Char('a')))]
382                fn baz(&mut self) {
383                    todo!()
384                }
385            }
386        };
387        let (orig_impl, keymap_impl) = keymap_impl(args, input).unwrap();
388        let expected_orig = parse2::<ItemImpl>(quote! {
389            impl Foo {
390                /// The first keybind
391                fn bar(&mut self) {
392                    todo!()
393                }
394
395                /// The second keybind
396                fn baz(&mut self) {
397                    todo!()
398                }
399            }
400        })
401        .unwrap();
402        let expected_keymap = parse_quote! {
403            impl ::ratatui_input_manager::KeyMap::<::ratatui_input_manager::TermionBackend> for Foo {
404                const KEYBINDS: &'static [::ratatui_input_manager::KeyBind::<::ratatui_input_manager::TermionBackend>] = &[
405                    ::ratatui_input_manager::KeyBind::<::ratatui_input_manager::TermionBackend> {
406                        pressed: &[
407                            ::ratatui_input_manager::KeyPress::<::ratatui_input_manager::TermionBackend> {
408                                key: Key::Esc,
409                                modifiers: (),
410                            },
411                            ::ratatui_input_manager::KeyPress::<::ratatui_input_manager::TermionBackend> {
412                                key: Key::Char('q'),
413                                modifiers: (),
414                            },
415                        ],
416                        description: "The first keybind",
417                    },
418                    ::ratatui_input_manager::KeyBind::<::ratatui_input_manager::TermionBackend> {
419                        pressed: &[
420                            ::ratatui_input_manager::KeyPress::<::ratatui_input_manager::TermionBackend> {
421                                key: Key::Char('a'),
422                                modifiers: (),
423                            },
424                        ],
425                        description: "The second keybind",
426                    }
427                ];
428
429                fn handle(&mut self, event: &<::ratatui_input_manager::TermionBackend as ::ratatui_input_manager::Backend>::Event) -> bool {
430                    match event {
431                        ::termion::event::Event::Key(
432                            ::termion::event::Key::Esc
433                        ) => { self.bar(); true }
434                        ::termion::event::Event::Key(
435                            ::termion::event::Key::Char('q')
436                        ) => { self.bar(); true }
437                        ::termion::event::Event::Key(
438                            ::termion::event::Key::Char('a')
439                        ) => { self.baz(); true }
440                        _ => false,
441                    }
442                }
443            }
444        };
445
446        assert_eq!(format_item(expected_orig), format_item(orig_impl));
447        assert_eq!(
448            format_item::<ItemImpl>(expected_keymap),
449            format_item(keymap_impl)
450        );
451    }
452
453    #[test]
454    fn test_generated_impl_termwiz() {
455        let args = KeyMapAttrs {
456            backend: Backend::Termwiz,
457        };
458        let input = parse_quote! {
459            impl Foo {
460                /// The first keybind
461                #[keybind(pressed(key=KeyCode::Escape))]
462                #[keybind(pressed(key=KeyCode::Char('q')))]
463                fn bar(&mut self) {
464                    todo!()
465                }
466
467                /// The second keybind
468                #[keybind(pressed(key=KeyCode::Char('a'), modifiers=Modifiers::CTRL, modifiers=Modifiers::SHIFT))]
469                fn baz(&mut self) {
470                    todo!()
471                }
472            }
473        };
474        let (orig_impl, keymap_impl) = keymap_impl(args, input).unwrap();
475        let expected_orig = parse2::<ItemImpl>(quote! {
476            impl Foo {
477                /// The first keybind
478                fn bar(&mut self) {
479                    todo!()
480                }
481
482                /// The second keybind
483                fn baz(&mut self) {
484                    todo!()
485                }
486            }
487        })
488        .unwrap();
489        let expected_keymap = parse_quote! {
490            impl ::ratatui_input_manager::KeyMap::<::ratatui_input_manager::TermwizBackend> for Foo {
491                const KEYBINDS: &'static [::ratatui_input_manager::KeyBind::<::ratatui_input_manager::TermwizBackend>] = &[
492                    ::ratatui_input_manager::KeyBind::<::ratatui_input_manager::TermwizBackend> {
493                        pressed: &[
494                            ::ratatui_input_manager::KeyPress::<::ratatui_input_manager::TermwizBackend> {
495                                key: KeyCode::Escape,
496                                modifiers: ::termwiz::input::Modifiers::NONE,
497                            },
498                            ::ratatui_input_manager::KeyPress::<::ratatui_input_manager::TermwizBackend> {
499                                key: KeyCode::Char('q'),
500                                modifiers: ::termwiz::input::Modifiers::NONE,
501                            },
502                        ],
503                        description: "The first keybind",
504                    },
505                    ::ratatui_input_manager::KeyBind::<::ratatui_input_manager::TermwizBackend> {
506                        pressed: &[
507                            ::ratatui_input_manager::KeyPress::<::ratatui_input_manager::TermwizBackend> {
508                                key: KeyCode::Char('a'),
509                                modifiers: ::termwiz::input::Modifiers::NONE
510                                    .union(Modifiers::CTRL)
511                                    .union(Modifiers::SHIFT),
512                            },
513                        ],
514                        description: "The second keybind",
515                    }
516                ];
517
518                fn handle(&mut self, event: &<::ratatui_input_manager::TermwizBackend as ::ratatui_input_manager::Backend>::Event) -> bool {
519                    match event {
520                        ::termwiz::input::InputEvent::Key(
521                            ::termwiz::input::KeyEvent {
522                                key: KeyCode::Escape,
523                                modifiers,
524                            },
525                        ) if modifiers.contains(::termwiz::input::Modifiers::NONE) => { self.bar(); true }
526                        ::termwiz::input::InputEvent::Key(
527                            ::termwiz::input::KeyEvent {
528                                key: KeyCode::Char('q'),
529                                modifiers,
530                            },
531                        ) if modifiers.contains(::termwiz::input::Modifiers::NONE) => { self.bar(); true }
532                        ::termwiz::input::InputEvent::Key(
533                            ::termwiz::input::KeyEvent {
534                                key: KeyCode::Char('a'),
535                                modifiers,
536                            },
537                        ) if modifiers
538                            .contains(
539                                ::termwiz::input::Modifiers::NONE
540                                    .union(Modifiers::CTRL)
541                                    .union(Modifiers::SHIFT),
542                            ) => { self.baz(); true }
543                        _ => false,
544                    }
545                }
546            }
547        };
548
549        assert_eq!(format_item(expected_orig), format_item(orig_impl));
550        assert_eq!(
551            format_item::<ItemImpl>(expected_keymap),
552            format_item(keymap_impl)
553        );
554    }
555
556    #[test]
557    fn test_missing_doc_comment_error() {
558        let args = KeyMapAttrs {
559            backend: Backend::Crossterm,
560        };
561        let input = parse_quote! {
562            impl Foo {
563                #[keybind(pressed(key=KeyCode::Esc))]
564                fn bar(&mut self) {
565                    todo!()
566                }
567            }
568        };
569        let result = keymap_impl(args, input);
570        assert!(result.is_err());
571        let err = result.unwrap_err();
572        assert_eq!(
573            err.to_string(),
574            "Keybind functions must have a doc comment for the description"
575        );
576    }
577}