From 5168640dee723bf9e76e459e2d14c6771de991ce Mon Sep 17 00:00:00 2001 From: Marut Khumtong Date: Sun, 22 Feb 2026 14:17:27 +0700 Subject: [PATCH] feat: support Key Group Capturing for dynamic variant data - Added `KeyMapConfig::bind()` for mapping matched key groups to enum payloads. - Implemented `get_bound_*` lookup methods to return owned, captured variants. - Modified macro to identify key group indices for all macros (@any, @digit, etc.). - Automated `Serialize` and `Deserialize` generation to prevent map-key collisions. - Refreshed project documentation and examples with the new terminology. --- README.md | 14 +++ examples/action.rs | 9 +- examples/backend/mock.rs | 2 +- examples/capturing.rs | 40 +++++++++ examples/config.rs | 5 ++ examples/derive.rs | 13 ++- examples/derived_config.rs | 10 ++- examples/modes.rs | 4 +- examples/sequences.rs | 9 +- examples/wasm/game.js | 24 ++++- examples/wasm/index.html | 8 +- examples/wasm/src/main.rs | 19 +++- keymap_derive/src/item.rs | 70 ++++++--------- keymap_derive/src/lib.rs | 159 ++++++++++++++++++++++++++++++++-- keymap_derive/tests/derive.rs | 94 +++++++++++++++++--- src/config.rs | 58 +++++++++++++ src/keymap.rs | 4 +- 17 files changed, 448 insertions(+), 94 deletions(-) create mode 100644 examples/capturing.rs diff --git a/README.md b/README.md index 74b3358..f08bca7 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ * ✅ **Declarative Key Mappings**: Define keymaps via simple configuration files (e.g., TOML, YAML) or directly in your code using derive macros. * âŒ¨ī¸ **Key Patterns**: Supports single keys (`a`), combinations (`ctrl-b`), and multi-key sequences (`ctrl-b n`). * 🧠 **Key Groups**: Use built-in pattern matching for common key groups (`@upper`, `@lower`, `@alpha`, `@alnum`, and `@any`). +* 📸 **Key Group Capturing**: Capture specific keypress data (like the actual `char` from `@any` or `@digit`) directly into your action enum variants at runtime. * đŸ§Ŧ **Compile-Time Safety**: The `keymap_derive` macro validates key syntax at compile time, preventing runtime errors. * 🌐 **Backend-Agnostic**: Works with multiple backends, including `crossterm`, `termion`, and `wasm`. * đŸĒļ **Lightweight & Extensible**: Designed to be minimal and easy to extend with new backends or features. @@ -97,6 +98,11 @@ pub enum Action { /// Jump. #[key("space")] Jump, + + /// Key Group Capturing action (e.g. tracking which character was pressed). + /// `char` will be captured from any matched key group macro (like `@any` or `@digit`) at runtime. + #[key("@any")] + Shoot(char), } ``` @@ -109,6 +115,7 @@ The `KeyMap` derive macro generates an associated `keymap_config()` method, whic let config = Action::keymap_config(); // `key` is a key code from the input backend, e.g., `crossterm::event::KeyCode` +// You can lookup the default pre-instantiated action reference: match config.get(&key) { Some(action) => match action { Action::Quit => break, @@ -117,8 +124,15 @@ match config.get(&key) { } _ => {} } + +// Or use Key Group Capturing to extract the actual `char` from `@any` or `@digit`! +if let Some(Action::Shoot(c)) = config.get_bound(&key) { + println!("Captured key: {c}"); +} ``` +> **Note**: `keymap_derive` automatically generates custom `Serialize` and `Deserialize` implementations for the derived `enum`, making your variants with captured data serialize as simple tags (e.g. `"Shoot"`) out of the box so that Map deserialization continues to work flawlessly. + ### 2. Using External Configuration `keymap-rs` also supports loading keymaps from external files (e.g., `config.toml`). This is useful for user-configurable keybindings. diff --git a/examples/action.rs b/examples/action.rs index 8ba7ca0..8f9fc26 100644 --- a/examples/action.rs +++ b/examples/action.rs @@ -1,7 +1,5 @@ -use serde::Deserialize; - #[cfg(feature = "derive")] -#[derive(Debug, keymap::KeyMap, Deserialize, Hash, PartialEq, Eq)] +#[derive(Debug, keymap::KeyMap, Hash, PartialEq, Eq, Clone)] pub(crate) enum Action { /// Jump over obstacles #[key("space", "@digit")] @@ -26,6 +24,11 @@ pub(crate) enum Action { /// Exit or pause game #[key("q", "esc")] Quit, + + /// Key Group Capturing action (e.g. tracking which character was pressed). + /// `char` will be captured from any matched key group macro (like `@any` or `@digit`) at runtime. + #[key("@any")] + Shoot(char), } #[allow(dead_code)] diff --git a/examples/backend/mock.rs b/examples/backend/mock.rs index 5447c94..cc16999 100644 --- a/examples/backend/mock.rs +++ b/examples/backend/mock.rs @@ -13,7 +13,7 @@ impl ToKeyMap for Key { } #[allow(dead_code)] -pub(crate) fn run(mut f: F) -> io::Result<()> +pub(crate) fn run(_f: F) -> io::Result<()> where F: FnMut(Key) -> bool, { diff --git a/examples/capturing.rs b/examples/capturing.rs new file mode 100644 index 0000000..a82dc09 --- /dev/null +++ b/examples/capturing.rs @@ -0,0 +1,40 @@ +#[path = "./backend/mod.rs"] +mod backend; + +#[path = "./action.rs"] +mod action; + +use crate::backend::{print, quit, run}; +use action::Action; +use keymap::{DerivedConfig, KeyMapConfig}; + +// In this example, we showcase Key Group Capturing using .get_bound() +// The Action::Shoot(char) variant is mapped to @any in action.rs. +pub(crate) const CONFIG: &str = r#" +Jump = { keys = ["j"], description = "Jump!" } +"#; + +fn main() -> std::io::Result<()> { + println!("# Example: Key Group Capturing using .get_bound()"); + println!("- Press any key to see it captured by Action::Shoot(char)"); + println!("- Press 'j' to see Action::Jump (unit variant)"); + println!("- Press 'q' or 'esc' to quit"); + + let config: DerivedConfig = toml::from_str(CONFIG).unwrap(); + + run(|key| match config.get_bound(&key) { + Some(action) => match action { + Action::Quit => quit("quit!"), + // This is matched via @any and the char is dynamically bound + Action::Shoot(c) => print(&format!("Matched @any! Captured character: '{c}'")), + // Standard unit variants work as before + Action::Jump | Action::Up | Action::Down | Action::Left | Action::Right => { + print(&format!( + "Action: {action:?} (Description: {})", + action.keymap_item().description + )) + } + }, + None => print(&format!("Unknown key {key:?}")), + }) +} diff --git a/examples/config.rs b/examples/config.rs index ca158f7..c9014b1 100644 --- a/examples/config.rs +++ b/examples/config.rs @@ -19,12 +19,17 @@ fn main() -> std::io::Result<()> { let config: Config = toml::from_str(CONFIG).unwrap(); + // Use .get() for high-performance reference lookup of the "default" variant. + // To capture the actual key pressed (e.g. the 'a' in @any), use .get_bound() + // or see the `capturing` example. run(|key| match config.get(&key) { Some(action) => match action { Action::Quit => quit("quit!"), + // Standard unit variants work as before Action::Up | Action::Down | Action::Left | Action::Right | Action::Jump => print( &format!("{action:?} = {}", action.keymap_item().description), ), + Action::Shoot(_) => print("Shoot! (Use .get_bound() to capture the character)"), }, None => print(&format!("Unknown key {key:?}")), }) diff --git a/examples/derive.rs b/examples/derive.rs index b06ce4e..1ee26f2 100644 --- a/examples/derive.rs +++ b/examples/derive.rs @@ -5,19 +5,24 @@ mod backend; mod action; use crate::backend::{print, quit, run}; -use keymap::KeyMapConfig; use action::Action; +use keymap::KeyMapConfig; fn main() -> std::io::Result<()> { println!("# Example: Using the KeyMap derive macro"); let config = Action::keymap_config(); + // Use .get() for high-performance reference lookup of the "default" variant. + // To capture the actual key pressed (e.g. the 'a' in @any), use .get_bound() + // or see the `capturing` example. run(|key| match config.get(&key) { Some(action) => match action { Action::Quit => quit("quit!"), - Action::Up | Action::Down | Action::Left | Action::Right | Action::Jump => { - print(&format!("{action:?}")) - } + Action::Shoot(_) => print("Shoot! (Use .get_bound() to capture the character)"), + // Standard unit variants work as before + Action::Jump | Action::Up | Action::Down | Action::Left | Action::Right => print( + &format!("{action:?} = {}", action.keymap_item().description), + ), }, None => print(&format!("Unknown key {key:?}")), }) diff --git a/examples/derived_config.rs b/examples/derived_config.rs index 6ae1a1c..38e9f3b 100644 --- a/examples/derived_config.rs +++ b/examples/derived_config.rs @@ -20,12 +20,16 @@ fn main() -> std::io::Result<()> { let config: DerivedConfig = toml::from_str(CONFIG).unwrap(); + // Use .get() for high-performance reference lookup of the "default" variant. + // To capture the actual key pressed (e.g. the 'a' in @any), use .get_bound() + // or see the `capturing` example. run(|key| match config.get(&key) { Some(action) => match action { Action::Quit => quit("quit!"), - Action::Up | Action::Down | Action::Left | Action::Right | Action::Jump => { - print(&format!("{action:?} = {}", action.keymap_item().description)) - } + Action::Shoot(_) => print("Shoot! (Use .get_bound() to capture the character)"), + Action::Up | Action::Down | Action::Left | Action::Right | Action::Jump => print( + &format!("{action:?} = {}", action.keymap_item().description), + ), }, None => print(&format!("Unknown key {key:?}")), }) diff --git a/examples/modes.rs b/examples/modes.rs index b90c56e..8aa5c0d 100644 --- a/examples/modes.rs +++ b/examples/modes.rs @@ -7,7 +7,7 @@ use crate::backend::{print, quit, run}; use keymap::DerivedConfig; use serde::Deserialize; -#[derive(keymap::KeyMap, Deserialize, Debug, Hash, Eq, PartialEq)] +#[derive(keymap::KeyMap, Debug, Hash, Eq, PartialEq, Clone)] enum HomeAction { #[key("esc")] Quit, @@ -15,7 +15,7 @@ enum HomeAction { Edit, } -#[derive(keymap::KeyMap, Deserialize, Debug, Hash, Eq, PartialEq)] +#[derive(keymap::KeyMap, Debug, Hash, Eq, PartialEq, Clone)] enum EditAction { #[key("esc")] Exit, diff --git a/examples/sequences.rs b/examples/sequences.rs index 79d7515..311d5e7 100644 --- a/examples/sequences.rs +++ b/examples/sequences.rs @@ -8,7 +8,7 @@ use std::time::{Duration, Instant}; use crate::backend::{print, quit, run, Key}; use action::Action; -use keymap::DerivedConfig; +use keymap::{DerivedConfig, KeyMapConfig}; // Override default key mapping defined via #[derive(KeyMap)] in Action. pub(crate) const CONFIG: &str = r#" @@ -26,9 +26,10 @@ fn main() -> std::io::Result<()> { let ret = match config.get(&key) { Some(action) => match action { Action::Quit => quit("quit!"), - Action::Up | Action::Down | Action::Left | Action::Right | Action::Jump => { - print(&format!("{action:?}")) - } + Action::Shoot(_) => print("Shoot!"), + Action::Up | Action::Down | Action::Left | Action::Right | Action::Jump => print( + &format!("{action:?} = {}", action.keymap_item().description), + ), }, None => { // Handle key sequence diff --git a/examples/wasm/game.js b/examples/wasm/game.js index a1408f1..616b5fa 100644 --- a/examples/wasm/game.js +++ b/examples/wasm/game.js @@ -279,9 +279,9 @@ class ObstacleManager { const spawnX = lastObstacle ? Math.max( - canvas.width, - lastObstacle.x + CONFIG.OBSTACLE.MIN_GAP + randomOffset, - ) + canvas.width, + lastObstacle.x + CONFIG.OBSTACLE.MIN_GAP + randomOffset, + ) : canvas.width + randomOffset; const type = @@ -412,8 +412,11 @@ class RainbowTrail { draw() { this.particles.forEach((particle) => { - ctx.fillStyle = Utils.hexToRgba(particle.color, particle.alpha); + ctx.save(); + ctx.globalAlpha = particle.alpha; + ctx.fillStyle = particle.color; ctx.fillRect(particle.x, particle.y, particle.width, particle.height); + ctx.restore(); }); } } @@ -693,3 +696,16 @@ export function pauseGame() { export function setKey(key, description) { game.setKey(key, description); } + +export function setSkin(c) { + // Handle char code or string character + const char = typeof c === 'number' ? String.fromCharCode(c) : c; + const digit = parseInt(char); + if (isNaN(digit)) return; + + // Change rainbow trail colors based on digit + const baseHue = (digit * 36) % 360; + game.rainbowTrail.colors = Array.from({ length: 6 }, (_, i) => { + return `hsl(${(baseHue + i * 20) % 360}, 100%, 50%)`; + }); +} diff --git a/examples/wasm/index.html b/examples/wasm/index.html index c71f4e6..0ee3047 100644 --- a/examples/wasm/index.html +++ b/examples/wasm/index.html @@ -38,7 +38,7 @@

EXAMPLE: Derive Macro

Define an enum and automatically derive key bindings using the #[derive(KeyMap)] macro.

-
#[derive(keymap::KeyMap, Deserialize, Hash, PartialEq, Eq)]
+    
#[derive(keymap::KeyMap, Hash, PartialEq, Eq, Clone)]
 pub enum Action {
   /// Jump over obstacles
   #[key("space")]
@@ -52,9 +52,9 @@ 

EXAMPLE: Derive Macro

#[key("right")] Right, - /// Pause - #[key("p")] - Pause, + /// Select a skin (Key Group Capturing!) + #[key("@digit")] + SelectSkin(char), /// Restart #[key("q", "esc")] diff --git a/examples/wasm/src/main.rs b/examples/wasm/src/main.rs index ef6a57c..484c2a9 100644 --- a/examples/wasm/src/main.rs +++ b/examples/wasm/src/main.rs @@ -1,5 +1,5 @@ use keymap::{DerivedConfig, ToKeyMap}; -use serde::Deserialize; + use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use web_sys::{window, KeyboardEvent}; @@ -13,9 +13,10 @@ extern "C" { fn resetGame(); fn pauseGame(); fn setKey(key: String, desc: String); + fn setSkin(c: char); } -#[derive(Debug, Clone, keymap::KeyMap, Deserialize, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, keymap::KeyMap, Hash, PartialEq, Eq)] pub enum Action { /// Jump over obstacles #[key("space")] @@ -36,6 +37,10 @@ pub enum Action { /// Restart #[key("q", "esc")] Quit, + + /// Select a skin + #[key("@digit")] + SelectSkin(char), } /// Overrides the default keymap @@ -45,6 +50,7 @@ Jump = { keys = ["space", "up"], description = "Jump Jump!" } Quit = { keys = ["q", "esc"], description = "Quit!" } Left = { keys = ["left", "alt-l"], description = "Move Left" } Right = { keys = ["right", "alt-r"], description = "Move Right" } +SelectSkin = { keys = ["@digit"], description = "Select a skin" } "#; #[allow(unused)] @@ -111,13 +117,20 @@ pub fn handle_key_event(event: &KeyboardEvent, is_keydown: bool) { setKey(key.to_string(), desc); } - if let Some(action) = config.get(event) { + // Use .get_bound() to support Key Group Capturing for SelectSkin + if let Some(action) = config.get_bound(event) { match action { Action::Quit => { if is_keydown { resetGame(); } } + Action::SelectSkin(c) => { + if is_keydown { + setSkin(c); + setKey(format!("Skin selected: {c}"), "Key Group Capturing!".to_string()); + } + } _ if !is_game_over => match action { Action::Left => moveLeft(is_keydown), Action::Right => moveRight(is_keydown), diff --git a/keymap_derive/src/item.rs b/keymap_derive/src/item.rs index 6544ad3..1ca9420 100644 --- a/keymap_derive/src/item.rs +++ b/keymap_derive/src/item.rs @@ -7,48 +7,49 @@ const DOC_IDENT: &str = "doc"; pub(crate) struct Item<'a> { pub variant: &'a Variant, + /// Raw string representations of the keys (e.g., ["ctrl-c", "@any"]). pub keys: Vec, - pub ignore: bool, - - #[allow(dead_code)] + /// Fully parsed nodes for each key sequence. Used for inspecting + /// key groups (like @any, @digit) during Key Group Capturing. pub nodes: Vec>, + pub ignore: bool, pub description: String, } -pub(crate) fn parse_items(variants: &Punctuated) -> Result>, syn::Error> { +pub(crate) fn parse_items( + variants: &Punctuated, +) -> Result>, syn::Error> { // NOTE: All variants are parsed, even those without the #[key(...)] attribute. // This allows the deserializer to override keys and descriptions for variants that don't define them explicitly. variants .iter() .map(|variant| { let ignore = parse_ignore(variant); + let (keys, nodes) = parse_keys(variant, ignore)?; Ok(Item { variant, ignore, description: parse_doc(variant), - keys: parse_keys(variant, ignore)?, - nodes: parse_nodes(variant, ignore)?, + keys, + nodes, }) }) .collect() } fn parse_ignore(variant: &Variant) -> bool { - variant - .attrs - .iter() - .any(|attr| { - let mut ignore = false; - if attr.path().is_ident(KEY_IDENT) { - let _ = attr.parse_nested_meta(|meta| { - ignore = meta.path.is_ident("ignore"); - Ok(()) - }); - } + variant.attrs.iter().any(|attr| { + let mut ignore = false; + if attr.path().is_ident(KEY_IDENT) { + let _ = attr.parse_nested_meta(|meta| { + ignore = meta.path.is_ident("ignore"); + Ok(()) + }); + } - ignore - }) + ignore + }) } fn parse_doc(variant: &Variant) -> String { @@ -81,30 +82,8 @@ fn parse_args(attr: &Attribute) -> syn::Result> { attr.parse_args_with(Punctuated::::parse_separated_nonempty) } -fn parse_keys(variant: &Variant, ignore: bool) -> syn::Result> { +fn parse_keys(variant: &Variant, ignore: bool) -> syn::Result<(Vec, Vec>)> { let mut keys = Vec::new(); - - for attr in &variant.attrs { - if !attr.path().is_ident(KEY_IDENT) || ignore { - continue; - } - - // Collect arguments - // - // e.g. [["a"], ["g g"]] - for arg in parse_args(attr)? { - let val = arg.value(); - parse_seq(&val) - .map_err(|e| syn::Error::new(arg.span(), format!("Invalid key \"{val}\": {e}")))?; - - keys.push(val); - } - } - - Ok(keys) -} - -fn parse_nodes(variant: &Variant, ignore: bool) -> syn::Result>> { let mut nodes = Vec::new(); for attr in &variant.attrs { @@ -117,12 +96,13 @@ fn parse_nodes(variant: &Variant, ignore: bool) -> syn::Result>> { // e.g. [["a"], ["g g"]] for arg in parse_args(attr)? { let val = arg.value(); - let keys = parse_seq(&val) + let seq = parse_seq(&val) .map_err(|e| syn::Error::new(arg.span(), format!("Invalid key \"{val}\": {e}")))?; - nodes.push(keys); + keys.push(val); + nodes.push(seq); } } - Ok(nodes) + Ok((keys, nodes)) } diff --git a/keymap_derive/src/lib.rs b/keymap_derive/src/lib.rs index b246e92..aaedab1 100644 --- a/keymap_derive/src/lib.rs +++ b/keymap_derive/src/lib.rs @@ -9,7 +9,7 @@ use syn::{DataEnum, DeriveInput, Fields, Ident}; mod item; -/// A derive macro that generates [`TryFrom`] implementations for enums. +/// A derive macro that generates keymap configuration logic from enums. /// /// # Example /// @@ -24,16 +24,27 @@ mod item; /// /// Delete an item /// #[key("x", "d")] /// Delete, +/// /// Captured character matched by any key group macro (like `@any` or `@digit`)! +/// #[key("@any")] +/// Jump(char), /// } /// -/// let keymap = keymap::parse("c").unwrap(); +/// let keymap = keymap::parse("a").unwrap(); /// let config = Action::keymap_config(); +/// +/// // Standard lookup returning a reference to the default `Jump('\0')` variant /// let action = config.get_by_keymap(&keymap).unwrap(); /// -/// assert_eq!(action, &Action::Create); -/// assert_eq!(action.keymap_item().description, "Create a new item"); +/// // Or use Key Group Capturing to extract the matched character from @any, @digit, etc.! +/// let bound_action = config.get_bound_by_keymap(&keymap).unwrap(); +/// assert_eq!(bound_action, Action::Jump('a')); /// ``` /// +/// **Note:** `keymap_derive` automatically generates specialized `serde::Serialize` +/// and `serde::Deserialize` implementations for the target `enum` allowing seamless +/// string-mapped configurations without users needing to configure `#[serde(untagged)]` +/// defaults for enum variants containing payloads. +/// /// # Attributes /// /// The `keymap_derive` crate supports the following attributes: @@ -69,6 +80,9 @@ pub fn keymap(input: TokenStream) -> TokenStream { fn impl_keymap_config(name: &Ident, items: &Vec) -> proc_macro2::TokenStream { let mut entries = Vec::new(); let mut match_arms = Vec::new(); + let mut match_arms_serialize = Vec::new(); + let mut match_arms_deserialize = Vec::new(); + let mut match_arms_bind = Vec::new(); for item in items { let ident = &item.variant.ident; @@ -79,15 +93,94 @@ fn impl_keymap_config(name: &Ident, items: &Vec) -> proc_macro2::TokenStre .collect::>(); let doc = &item.description; - let variant = match &item.variant.fields { + // Find the index of a key group (like @any, @digit, etc.) in the parsed nodes. + // For simplicity, we only check the first key mapped to this variant. + // If the first key contains a group, the matched node at that index will be a `Char`. + let mut char_idx: Option = None; + if let Some(first_node_seq) = item.nodes.first() { + for (idx, node) in first_node_seq.iter().enumerate() { + if let ::keymap_parser::node::Key::Group(_) = node.key { + char_idx = Some(idx); + } + } + } + + let extract_char = if let Some(idx) = char_idx { + quote! { + match keys.get(#idx).map(|n| &n.key) { + Some(::keymap_parser::node::Key::Char(c)) => *c, + _ => Default::default(), + } + } + } else { + quote! { Default::default() } + }; + + let variant_expr = match &item.variant.fields { + Fields::Unit => quote! { #name::#ident }, + Fields::Unnamed(fields) => { + let defaults = fields.unnamed.iter().map(|f| { + let ty_str = quote!(#f).to_string(); + if ty_str == "char" { + extract_char.clone() + } else { + quote! { Default::default() } + } + }); + quote! { #name::#ident(#(#defaults),*) } + } + Fields::Named(fields) => { + let defaults = fields.named.iter().map(|f| { + let name = f.ident.as_ref().unwrap(); + let ty_str = quote!(#f).to_string(); + if ty_str.contains("char") { + quote! { #name: #extract_char } + } else { + quote! { #name: Default::default() } + } + }); + quote! { #name::#ident { #(#defaults),* } } + } + }; + + let variant_expr_default = match &item.variant.fields { + Fields::Unit => quote! { #name::#ident }, + Fields::Unnamed(fields) => { + let defaults = fields.unnamed.iter().map(|_| quote! { Default::default() }); + quote! { #name::#ident(#(#defaults),*) } + } + Fields::Named(fields) => { + let defaults = fields.named.iter().map(|f| { + let name = &f.ident; + quote! { #name: Default::default() } + }); + quote! { #name::#ident { #(#defaults),* } } + } + }; + + let variant_pat = match &item.variant.fields { Fields::Unit => quote! { #name::#ident }, Fields::Unnamed(_) => quote! { #name::#ident(..) }, Fields::Named(_) => quote! { #name::#ident { .. } }, }; + let variant_name_str = ident.to_string(); + + match_arms_serialize.push(quote! { + #variant_pat => #variant_name_str, + }); + + match_arms_deserialize.push(quote! { + #variant_name_str => Ok(#variant_expr_default), + }); + + match_arms_bind.push(quote! { + #variant_pat => #variant_expr, + }); + // keymap_item match_arms.push(quote! { - #variant => ::keymap::Item::new( + #variant_pat => ::keymap::Item::new( vec![#(#keys),*], #doc.to_string() ), @@ -97,7 +190,7 @@ fn impl_keymap_config(name: &Ident, items: &Vec) -> proc_macro2::TokenStre if !item.ignore { entries.push(quote! { ( - #variant, + #variant_expr_default, ::keymap::Item::new( vec![#(#keys),*], #doc.to_string() @@ -107,6 +200,47 @@ fn impl_keymap_config(name: &Ident, items: &Vec) -> proc_macro2::TokenStre } } + let serde_impls = quote! { + impl ::serde::Serialize for #name { + fn serialize(&self, serializer: S) -> Result + where + S: ::serde::Serializer, + { + let variant_name = match self { + #(#match_arms_serialize)* + }; + serializer.serialize_str(variant_name) + } + } + + impl<'de> ::serde::Deserialize<'de> for #name { + fn deserialize(deserializer: D) -> Result + where + D: ::serde::Deserializer<'de>, + { + struct EnumVisitor; + impl<'de> ::serde::de::Visitor<'de> for EnumVisitor { + type Value = #name; + + fn expecting(&self, formatter: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { + formatter.write_str("a valid variant name for #name") + } + + fn visit_str(self, value: &str) -> Result + where + E: ::serde::de::Error, + { + match value { + #(#match_arms_deserialize)* + _ => Err(E::unknown_variant(value, &[])), + } + } + } + deserializer.deserialize_str(EnumVisitor) + } + } + }; + quote! { impl ::keymap::KeyMapConfig<#name> for #name { fn keymap_config() -> ::keymap::Config<#name> { @@ -118,6 +252,17 @@ fn impl_keymap_config(name: &Ident, items: &Vec) -> proc_macro2::TokenStre #(#match_arms)* } } + + fn bind(&self, keys: &[::keymap::KeyMap]) -> Self + where + Self: Clone, + { + match self { + #(#match_arms_bind)* + } + } } + + #serde_impls } } diff --git a/keymap_derive/tests/derive.rs b/keymap_derive/tests/derive.rs index bf61d2a..bb65236 100644 --- a/keymap_derive/tests/derive.rs +++ b/keymap_derive/tests/derive.rs @@ -1,31 +1,42 @@ -use serde::Deserialize; - // TODO: Fix release-please bug. See https://github.com/googleapis/release-please/issues/1662#issuecomment-1419080151 extern crate keymap_dev as keymap; -#[derive(Debug, PartialEq, Eq, keymap_derive::KeyMap, Deserialize)] +#[derive(Debug, PartialEq, Eq, keymap_derive::KeyMap, Clone)] enum Action { /// Create a new file. /// Multi-line support. #[key("enter", "ctrl-b n")] Create, /// Delete a file - #[key("d", "delete", "d d", "@lower", "@digit")] + #[key("d", "delete", "d d", "@lower")] Delete, /// Quit - #[key("@any")] + #[key("esc", "q")] Quit, - #[key(ignore)] - Hello(char) + /// Digit with char argument + #[key("@digit")] + Digit(char), + + /// Jump with char argument + #[key("@any")] + Jump(char), } #[cfg(test)] mod tests { - use keymap_dev::{Item, KeyMapConfig}; + use keymap_dev::{Error, Item, KeyMap, KeyMapConfig, ToKeyMap}; use super::*; + struct Wrapper(keymap_parser::Node); + + impl ToKeyMap for Wrapper { + fn to_keymap(&self) -> Result { + Ok(self.0.clone()) + } + } + #[test] fn test_derive_key() { let config = Action::keymap_config(); @@ -47,8 +58,8 @@ mod tests { let config = Action::keymap_config(); [ - (Action::Delete, "x"), // @lower - (Action::Delete, "1"), // @digit + (Action::Delete, "x"), // @lower + (Action::Digit('\0'), "1"), // @digit ] .map(|(action, input)| { let key = keymap_parser::parse_seq(input).unwrap(); @@ -73,7 +84,7 @@ mod tests { ( Action::Delete, Item::new( - ["d", "delete", "d d", "@lower", "@digit"] + ["d", "delete", "d d", "@lower"] .map(ToString::to_string) .to_vec(), "Delete a file".to_string() @@ -82,11 +93,70 @@ mod tests { ( Action::Quit, Item::new( - ["@any"].map(ToString::to_string).to_vec(), + ["esc", "q"].map(ToString::to_string).to_vec(), "Quit".to_string() ) ), + ( + Action::Digit('\0'), + Item::new( + ["@digit"].map(ToString::to_string).to_vec(), + "Digit with char argument".to_string() + ) + ), + ( + Action::Jump('\0'), + Item::new( + ["@any"].map(ToString::to_string).to_vec(), + "Jump with char argument".to_string() + ) + ), ] ); } + + #[test] + fn test_bound_payload_extraction() { + let config = Action::keymap_config(); + + // When we press '1', it matches @digit, and we should extract '1' + let keys = keymap_parser::parse_seq("1") + .unwrap() + .into_iter() + .map(Wrapper) + .collect::>(); + let bound_action = config.get_bound_seq(&keys).unwrap(); + assert_eq!(bound_action, Action::Digit('1')); + + // When we press 'A', it matches @any, and we should extract 'A' + let keys = keymap_parser::parse_seq("A") + .unwrap() + .into_iter() + .map(Wrapper) + .collect::>(); + let bound_action = config.get_bound_seq(&keys).unwrap(); + + assert_eq!(bound_action, Action::Jump('A')); + + // When we press 'Q', it matches @any, and we should extract 'Q' + let keys = keymap_parser::parse_seq("Q") + .unwrap() + .into_iter() + .map(Wrapper) + .collect::>(); + let nodes = keys.iter().map(|k| k.0.clone()).collect::>(); + let (bound_action, item) = config.get_bound_item_by_keymaps(&nodes).unwrap(); + + assert_eq!(bound_action, Action::Jump('Q')); + assert_eq!(item.description, "Jump with char argument"); + + // Standard keys should extract as well using get_bound_seq + let keys = keymap_parser::parse_seq("enter") + .unwrap() + .into_iter() + .map(Wrapper) + .collect::>(); + let bound_action = config.get_bound_seq(&keys).unwrap(); + assert_eq!(bound_action, Action::Create); + } } diff --git a/src/config.rs b/src/config.rs index a1e425c..c0e01e7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,6 +93,18 @@ pub trait KeyMapConfig { /// assert_eq!(item.description, "Create an item"); /// ``` fn keymap_item(&self) -> Item; + + /// Binds the given parsed key sequence (`keys`) to this variant, potentially + /// extracting values (such as a `char` from an `@any` group) and returning + /// a new instance of the variant. + /// + /// By default, this simply clones the variant if it has no data fields. + fn bind(&self, _keys: &[KeyMap]) -> Self + where + Self: Clone, + { + self.clone() + } } /// A deserializable configuration structure that maps keys to items. @@ -304,6 +316,20 @@ impl Config { self.get_item_by_keymaps(&nodes).map(|(t, _)| t) } + /// Retrieve the dynamically bound key type `T` by extracting payload data + /// from a sequence of parsed key events. + pub fn get_bound_seq(&self, keys: &[K]) -> Option + where + T: KeyMapConfig + Clone, + { + let nodes = keys + .iter() + .map(|key| key.to_keymap().ok()) + .collect::>>()?; + + self.get_bound_item_by_keymaps(&nodes).map(|(t, _)| t) + } + /// Lookup an `(T, Item)` pair by a parsed `KeyMap`, returning a /// reference to the key type `T` and the associated `Item` if found. /// @@ -333,6 +359,27 @@ impl Config { self.get_item_by_keymap(node).map(|(t, _)| t) } + /// Retrieve the dynamically bound key type `T` by extracting payload data + /// (such as a matched `char` from `@any`) from the given key event. + /// + /// Requires `T: KeyMapConfig + Clone` to use the `bind` method. + pub fn get_bound(&self, key: &K) -> Option + where + T: KeyMapConfig + Clone, + { + self.get_bound_by_keymap(&key.to_keymap().ok()?) + } + + /// Retrieve the dynamically bound key type `T` by extracting payload data + /// from a single parsed `KeyMap`. + pub fn get_bound_by_keymap(&self, node: &KeyMap) -> Option + where + T: KeyMapConfig + Clone, + { + let keys = std::slice::from_ref(node); + self.get_item_by_keymaps(keys).map(|(t, _)| t.bind(keys)) + } + /// Lookup an `(T, Item)` pair by an entire slice of parsed [`type@KeyMap`]s. /// This performs an exact match against one of the stored `Vec`. /// @@ -356,6 +403,17 @@ impl Config { .map(|i| (&self.items[*i].0, &self.items[*i].1)) } + /// Lookup an `(T, Item)` pair by an entire slice of parsed [`type@KeyMap`]s, + /// and dynamically bind the matched keys to construct a new `T`. + pub fn get_bound_item_by_keymaps(&self, keys: &[KeyMap]) -> Option<(T, &Item)> + where + T: KeyMapConfig + Clone, + { + self.matcher + .get(keys) + .map(|i| (self.items[*i].0.bind(keys), &self.items[*i].1)) + } + /// Lookup an `(T, Item)` by a raw string. This will attempt to parse the /// string through `parse_seq` and then perform a lookup on the resulting /// slice of `KeyMap`s. diff --git a/src/keymap.rs b/src/keymap.rs index 56435fb..d2e6889 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -75,7 +75,7 @@ pub enum Error { impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Error::Parse(e) => write!(f, "{e}"), + Error::Parse(e) => write!(f, "{e}"), Error::UnsupportedKey(k) => write!(f, "{k}"), } } @@ -84,7 +84,7 @@ impl std::fmt::Display for Error { impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Error::Parse(e) => Some(e), + Error::Parse(e) => Some(e), Error::UnsupportedKey(_) => None, } }