From 3361917823e4b841c7afb84affdb5750cd90571b Mon Sep 17 00:00:00 2001 From: Antoni Simka Date: Wed, 28 Apr 2021 13:27:49 +0200 Subject: [PATCH] Add a procedural macro for defining layouts (#54) --- .gitignore | 4 +- CHANGELOG.md | 11 ++- Cargo.toml | 1 + keyberon-macros/Cargo.toml | 16 +++ keyberon-macros/src/lib.rs | 185 +++++++++++++++++++++++++++++++++++ keyberon-macros/tests/mod.rs | 90 +++++++++++++++++ src/layout.rs | 45 +++++++++ 7 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 keyberon-macros/Cargo.toml create mode 100644 keyberon-macros/src/lib.rs create mode 100644 keyberon-macros/tests/mod.rs diff --git a/.gitignore b/.gitignore index afe70d9..18c3e08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/Cargo.lock -/target +Cargo.lock +target/ **/*.rs.bk *~ diff --git a/CHANGELOG.md b/CHANGELOG.md index bfff902..69258cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,19 @@ * New Keyboard::leds_mut function for getting underlying leds object. * Made Layout::current_layer public for getting current active layer. +* Added a procedural macro for defining layouts (`keyberon::layout::layout`) Breaking changes: -* Update to generic_array 0.14, that is exposed in matrix. The update +* Update to generic_array 0.14, which is exposed in matrix. The update should be transparent. * `Action::HoldTap` now takes a configuration for different behaviors. * `Action::HoldTap` now takes the `tap_hold_interval` field. Not implemented yet. * `Action` is now generic, for the `Action::Custom(T)` variant, - allowing custom action to be handled outside of keyberon. This - functionality can be used to drive non keyboard actions, as reset - the microcontroller, drive leds (for backlight or underglow for - example), manage a mouse emulation, or any other ideas you can + allowing custom actions to be handled outside of keyberon. This + functionality can be used to drive non keyboard actions, such as resetting + the microcontroller, driving leds (for backlight or underglow for + example), managing a mouse emulation, or any other ideas you can have. As there is a default value for the type parameter, the update should be transparent. * Rename MeidaCoffee in MediaCoffee to fix typo. diff --git a/Cargo.toml b/Cargo.toml index d3552c6..608a684 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ license = "MIT" readme = "README.md" [dependencies] +keyberon-macros = { version = "0.1.0", path = "./keyberon-macros" } either = { version = "1.6", default-features = false } generic-array = "0.14" embedded-hal = { version = "0.2", features = ["unproven"] } diff --git a/keyberon-macros/Cargo.toml b/keyberon-macros/Cargo.toml new file mode 100644 index 0000000..5bb1fa6 --- /dev/null +++ b/keyberon-macros/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "keyberon-macros" +version = "0.1.0" +authors = ["Antoni Simka "] +edition = "2018" + +[lib] +proc-macro = true + +[dependencies] +proc-macro-error = "1.0.4" +proc-macro2 = "1.0" +quote = "1.0" + +[dev-dependencies] +keyberon = { path = "../" } diff --git a/keyberon-macros/src/lib.rs b/keyberon-macros/src/lib.rs new file mode 100644 index 0000000..b3288c5 --- /dev/null +++ b/keyberon-macros/src/lib.rs @@ -0,0 +1,185 @@ +extern crate proc_macro; +use proc_macro2::{Delimiter, Group, Literal, Punct, Spacing, TokenStream, TokenTree}; +use proc_macro_error::proc_macro_error; +use proc_macro_error::{abort, emit_error}; +use quote::quote; + +#[proc_macro_error] +#[proc_macro] +pub fn layout(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input: TokenStream = input.into(); + + let mut out = TokenStream::new(); + + let mut inside = TokenStream::new(); + + for t in input { + match t { + TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => { + let layer = parse_layer(g.stream()); + inside.extend(quote! { + &[#layer], + }); + } + _ => abort!(t, "Invalid token, expected layer: {{ ... }}"), + } + } + + let all: TokenStream = quote! { &[#inside] }; + out.extend(all); + + out.into() +} + +fn parse_layer(input: TokenStream) -> TokenStream { + let mut out = TokenStream::new(); + for t in input { + match t { + TokenTree::Group(g) if g.delimiter() == Delimiter::Bracket => { + let row = parse_row(g.stream()); + out.extend(quote! { + &[#row], + }); + } + TokenTree::Punct(p) if p.as_char() == ',' => (), + _ => abort!(t, "Invalid token, expected row: [ ... ]"), + } + } + out +} + +fn parse_row(input: TokenStream) -> TokenStream { + let mut out = TokenStream::new(); + for t in input { + match t { + TokenTree::Ident(i) => match i.to_string().as_str() { + "n" => out.extend(quote! { keyberon::action::Action::NoOp, }), + "t" => out.extend(quote! { keyberon::action::Action::Trans, }), + _ => out.extend(quote! { + keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::#i), + }), + }, + TokenTree::Punct(p) => punctuation_to_keycode(&p, &mut out), + TokenTree::Literal(l) => literal_to_keycode(&l, &mut out), + TokenTree::Group(g) => parse_group(&g, &mut out), + } + } + out +} + +fn parse_group(g: &Group, out: &mut TokenStream) { + match g.delimiter() { + // Handle empty groups + Delimiter::Parenthesis if g.stream().is_empty() => { + emit_error!(g, "Expected a layer number in layer switch"; help = "To create a parenthesis keycode, enclose it in apostrophes: '('") + } + Delimiter::Brace if g.stream().is_empty() => { + emit_error!(g, "Expected an action - group cannot be empty"; help = "To create a brace keycode, enclose it in apostrophes: '{'") + } + Delimiter::Bracket if g.stream().is_empty() => { + emit_error!(g, "Expected keycodes - keycode group cannot be empty"; help = "To create a bracket keycode, enclose it in apostrophes: '['") + } + + // Momentary layer switch (Action::Layer) + Delimiter::Parenthesis => { + let tokens = g.stream(); + out.extend(quote! { keyberon::action::Action::Layer(#tokens), }); + } + // Pass the expression unchanged (adding a comma after it) + Delimiter::Brace => out.extend(g.stream().into_iter().chain(TokenStream::from( + TokenTree::Punct(Punct::new(',', Spacing::Alone)), + ))), + // Multiple keycodes (Action::MultipleKeyCodes) + Delimiter::Bracket => parse_keycode_group(g.stream(), out), + + // Is this reachable? + Delimiter::None => emit_error!(g, "Unexpected group"), + } +} + +fn parse_keycode_group(input: TokenStream, out: &mut TokenStream) { + let mut inner = TokenStream::new(); + for t in input { + match t { + TokenTree::Ident(i) => inner.extend(quote! { + keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::#i), + }), + TokenTree::Punct(p) => punctuation_to_keycode(&p, &mut inner), + TokenTree::Literal(l) => literal_to_keycode(&l, &mut inner), + TokenTree::Group(g) => parse_group(&g, &mut inner), + } + } + out.extend(quote! { keyberon::action::Action::MultipleActions(&[#inner]) }); +} + +fn punctuation_to_keycode(p: &Punct, out: &mut TokenStream) { + match p.as_char() { + // Normal punctuation + '-' => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Minus), }), + '=' => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Equal), }), + ';' => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::SColon), }), + ',' => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Comma), }), + '.' => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Dot), }), + '/' => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Slash), }), + + // Shifted punctuation + '!' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Kb1]), }), + '@' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Kb2]), }), + '#' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Kb3]), }), + '$' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Kb4]), }), + '%' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Kb5]), }), + '^' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Kb6]), }), + '&' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Kb7]), }), + '*' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Kb8]), }), + '_' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Minus]), }), + '+' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Equal]), }), + '|' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Bslash]), }), + '~' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Grave]), }), + '<' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Comma]), }), + '>' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Dot]), }), + '?' => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Slash]), }), + // Is this reachable? + _ => emit_error!(p, "Punctuation could not be parsed as a keycode") + } +} + +fn literal_to_keycode(l: &Literal, out: &mut TokenStream) { + //let repr = l.to_string(); + match l.to_string().as_str() { + "1" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Kb1), }), + "2" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Kb2), }), + "3" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Kb3), }), + "4" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Kb4), }), + "5" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Kb5), }), + "6" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Kb6), }), + "7" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Kb7), }), + "8" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Kb8), }), + "9" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Kb9), }), + "0" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Kb0), }), + + // Char literals; mostly punctuation which can't be properly tokenized alone + r#"'\''"# => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Quote), }), + r#"'\\'"# => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Bslash), }), + // Shifted characters + "'['" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::LBracket), }), + "']'" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::RBracket), }), + "'`'" => out.extend(quote! { keyberon::action::Action::KeyCode(keyberon::key_code::KeyCode::Grave), }), + "'\"'" => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Quote]), }), + "'('" => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Kb9]), }), + "')'" => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Kb0]), }), + "'{'" => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::LBracket]), }), + "'}'" => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::RBracket]), }), + "'_'" => out.extend(quote! { keyberon::action::Action::MultipleKeyCodes(&[keyberon::key_code::KeyCode::LShift, keyberon::key_code::KeyCode::Minus]), }), + + s if s.starts_with('\'') => emit_error!(l, "Literal could not be parsed as a keycode"; help = "Maybe try without quotes?"), + + s if s.starts_with('\"') => { + if s.len() == 3 { + emit_error!(l, "Typing strings on key press is not yet supported"; help = "Did you mean to use apostrophes instead of quotes?"); + } else { + emit_error!(l, "Typing strings on key press is not yet supported"); + } + } + _ => emit_error!(l, "Literal could not be parsed as a keycode") + } +} diff --git a/keyberon-macros/tests/mod.rs b/keyberon-macros/tests/mod.rs new file mode 100644 index 0000000..4ce3af8 --- /dev/null +++ b/keyberon-macros/tests/mod.rs @@ -0,0 +1,90 @@ +extern crate keyberon_macros; +use keyberon::action::{k, l, m, Action, Action::*, HoldTapConfig}; +use keyberon::key_code::KeyCode::*; +use keyberon_macros::layout; + +#[test] +fn test_layout_equality() { + macro_rules! s { + ($k:expr) => { + m(&[LShift, $k]) + }; + } + + static S_ENTER: Action = Action::HoldTap { + timeout: 280, + hold: &Action::KeyCode(RShift), + tap: &Action::KeyCode(Enter), + config: HoldTapConfig::PermissiveHold, + tap_hold_interval: 0, + }; + + #[rustfmt::skip] + pub static LAYERS_OLD: keyberon::layout::Layers = &[ + &[ + &[k(Tab), k(Q), k(W), k(E), k(R), k(T), k(Y), k(U), k(I), k(O), k(P), k(BSpace)], + &[k(LCtrl), k(A), k(S), k(D), k(F), k(G), k(H), k(J), k(K), k(L), k(SColon), k(Quote) ], + &[k(LShift), k(Z), k(X), k(C), k(V), k(B), k(N), k(M), k(Comma), k(Dot), k(Slash), k(Escape)], + &[NoOp, NoOp, k(LGui), l(1), k(Space), k(Escape), k(BSpace), S_ENTER, l(1), k(RAlt), NoOp, NoOp], + ], + &[ + &[k(Tab), k(Kb1), k(Kb2), k(Kb3), k(Kb4), k(Kb5), k(Kb6), k(Kb7), k(Kb8), k(Kb9), k(Kb0), k(BSpace)], + &[k(LCtrl), s!(Kb1), s!(Kb2), s!(Kb3), s!(Kb4), s!(Kb5), s!(Kb6), s!(Kb7), s!(Kb8), s!(Kb9), s!(Kb0), MultipleActions(&[k(LCtrl), k(Grave)])], + &[k(LShift), NoOp, NoOp, NoOp, NoOp, NoOp, k(Left), k(Down), k(Up), k(Right), NoOp, s!(Grave)], + &[NoOp, NoOp, k(LGui), Trans, Trans, Trans, Trans, Trans, Trans, k(RAlt), NoOp, NoOp], + ], + ]; + + pub static LAYERS: keyberon::layout::Layers = layout! { + { + [ Tab Q W E R T Y U I O P BSpace ] + [ LCtrl A S D F G H J K L ; Quote ] + [ LShift Z X C V B N M , . / Escape ] + [ n n LGui (1) Space Escape BSpace {S_ENTER} (1) RAlt n n ] + } + { + [ Tab 1 2 3 4 5 6 7 8 9 0 BSpace ] + [ LCtrl ! @ # $ % ^ & * '(' ')' [LCtrl '`'] ] + [ LShift n n n n n Left Down Up Right n ~ ] + [ n n LGui t t t t t t RAlt n n ] + } + }; + + assert_eq!(LAYERS, LAYERS_OLD); + use std::mem::size_of_val; + assert_eq!(size_of_val(LAYERS), size_of_val(LAYERS_OLD)) +} + +#[test] +fn test_nesting() { + static A: keyberon::layout::Layers = layout! { + { + [{k(D)} [(5) [C {k(D)}]]] + } + }; + static B: keyberon::layout::Layers = &[&[&[ + k(D), + Action::MultipleActions(&[Action::Layer(5), Action::MultipleActions(&[k(C), k(D)])]), + ]]]; + assert_eq!(A, B); +} + +#[test] +fn test_layer_switch() { + static A: keyberon::layout::Layers = layout! { + { + [(0xa), (0b0110), (b'a' as usize), (1 + 8 & 32), ([4,5][0])] + } + }; +} + +#[test] +fn test_escapes() { + static A: keyberon::layout::Layers = layout! { + { + ['\\' '\''] + } + }; + static B: keyberon::layout::Layers = &[&[&[k(Bslash), k(Quote)]]]; + assert_eq!(A, B); +} diff --git a/src/layout.rs b/src/layout.rs index eb4e2c2..550609f 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1,5 +1,50 @@ //! Layout management. +/// A procedural macro to generate [Layers](type.Layers.html) +/// ## Syntax +/// Items inside the macro are converted to Actions as such: +/// - [`Action::KeyCode`]: Idents are automatically understood as keycodes: `A`, `RCtrl`, `Space` +/// - Punctuation, numbers and other literals that aren't special to the rust parser are converted +/// to KeyCodes as well: `,` becomes `KeyCode::Commma`, `2` becomes `KeyCode::Kb2`, `/` becomes `KeyCode::Slash` +/// - Characters which require shifted keys are converted to `Action::MultipleKeyCodes(&[LShift, ])`: +/// `!` becomes `Action::MultipleKeyCodes(&[LShift, Kb1])` etc +/// - Characters special to the rust parser (parentheses, brackets, braces, quotes, apostrophes, underscores, backslashes and backticks) +/// left alone cause parsing errors and as such have to be enclosed by apostrophes: `'['` becomes `KeyCode::LBracket`, +/// `'\''` becomes `KeyCode::Quote`, `'\\'` becomes `KeyCode::BSlash` +/// - [`Action::NoOp`]: Lowercase `n` +/// - [`Action::Trans`]: Lowercase `t` +/// - [`Action::Layer`]: A number in parentheses: `(1)`, `(4 - 2)`, `(0x4u8 as usize)` +/// - [`Action::MultipleActions`]: Actions in brackets: `[LCtrl S]`, `[LAlt LCtrl C]`, `[(2) B {Action::NoOp}]` +/// - Other `Action`s: anything in braces (`{}`) is copied unchanged to the final layout - `{ Action::Custom(42) }` +/// simply becomes `Action::Custom(42)` +/// +/// **Important note**: comma (`,`) is a keycode on its own, and can't be used to separate keycodes as one would have +/// to do when not using a macro. +/// +/// ## Usage example: +/// Example layout for a 4x12 split keyboard: +/// ``` +/// use keyberon::action::Action; +/// static DLAYER: Action = Action::DefaultLayer(5); +/// +/// pub static LAYERS: keyberon::layout::Layers = keyberon::layout::layout! { +/// { +/// [ Tab Q W E R T Y U I O P BSpace ] +/// [ LCtrl A S D F G H J K L ; Quote ] +/// [ LShift Z X C V B N M , . / Escape ] +/// [ n n LGui {DLAYER} Space Escape BSpace Enter (1) RAlt n n ] +/// } +/// { +/// [ Tab 1 2 3 4 5 6 7 8 9 0 BSpace ] +/// [ LCtrl ! @ # $ % ^ & * '(' ')' - = ] +/// [ LShift n n n n n n n n n n [LAlt A]] +/// [ n n LGui (2) t t t t t RAlt n n ] +/// } +/// // ... +/// }; +/// ``` +pub use keyberon_macros::*; + use crate::action::{Action, HoldTapConfig}; use crate::key_code::KeyCode; use arraydeque::ArrayDeque;