diff --git a/README.md b/README.md index f08bca7..235bbaa 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ * â¨ī¸ **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. +* đˇī¸ **Custom Symbols & Help**: Define custom display symbols (e.g., `^B`) and help text for key bindings. * đ§Ŧ **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. @@ -95,8 +96,8 @@ pub enum Action { #[key("right", "l")] Right, - /// Jump. - #[key("space")] + /// Jump with custom key, display symbol and help text. + #[key("space", symbol = "âŖ", help = "jump over obstacles")] Jump, /// Key Group Capturing action (e.g. tracking which character was pressed). @@ -119,7 +120,8 @@ let config = Action::keymap_config(); match config.get(&key) { Some(action) => match action { Action::Quit => break, - Action::Jump => println!("Jump!"), + Action::Jump => println!("Jump! Symbol: {:?}, Help: {:?}", + action.keymap_item().symbol, action.keymap_item().help), _ => println!("Action: {action:?} - {}", action.keymap_item().description), } _ => {} @@ -241,6 +243,8 @@ assert_eq!( | **Key Combinations** | Keys pressed simultaneously with modifiers (Ctrl, Alt, Shift). | `ctrl-c`, `alt-f4`, `ctrl-alt-shift-f1` | | **Key Sequences** | Multiple keys pressed in order. | `g g` (press `g` twice), `ctrl-b n` (Ctrl+B, then N), `ctrl-b c` (tmux-style new window) | | **Key Groups** | Predefined patterns matching sets of keys. | `@upper` (A-Z), `@alpha` (A-Z, a-z), `@any` (any key) | +| **Custom Symbols** | Custom display symbols for key bindings (e.g., for UI display). | `symbol = "^B"` | +| **Help Text** | Short help descriptions for key bindings. | `help = "jump"` | **Examples in Configuration:** ```toml diff --git a/examples/wasm/game.js b/examples/wasm/game.js index 616b5fa..0e1222d 100644 --- a/examples/wasm/game.js +++ b/examples/wasm/game.js @@ -470,7 +470,8 @@ class Game { this.gameSpeed = CONFIG.GAME.INITIAL_SPEED; this.gameOver = false; this.paused = false; - this.key = ""; + this.symbol = ""; + this.help = ""; // Delta time approach instead of frame limiting this.lastTime = 0; @@ -501,7 +502,8 @@ class Game { this.gameSpeed = CONFIG.GAME.INITIAL_SPEED; this.gameOver = false; this.paused = false; - this.key = ""; + this.symbol = ""; + this.help = ""; this._hideGameOverUI(); this.accumulator = 0; // Ensure game logic runs on first frame after reset @@ -512,11 +514,9 @@ class Game { }); } - setKey(key, desc) { - this.key = [key, desc] - .filter(Boolean) - .map((s) => s.toLowerCase()) - .join(" - "); + setKey(symbol = "", help = "") { + this.symbol = symbol; + this.help = help; } togglePause() { @@ -551,14 +551,13 @@ class Game { } _drawKey() { - let fontSize = 10; - ctx.fillStyle = "#ccc"; - ctx.font = `${fontSize}px "${CONFIG.GAME.FONT_FACE}"`; ctx.textAlign = "center"; + ctx.fillStyle = "#ccc"; + ctx.font = `10px "${CONFIG.GAME.FONT_FACE}"`; ctx.fillText( - this.key, + [this.symbol, this.help].filter(Boolean).join(" "), canvas.width / 2, - GROUND_Y + CONFIG.GROUND_HEIGHT - (CONFIG.GROUND_HEIGHT - fontSize) / 2, + GROUND_Y + CONFIG.GROUND_HEIGHT - (CONFIG.GROUND_HEIGHT - 10) / 2, ); } @@ -693,17 +692,30 @@ export function pauseGame() { } } -export function setKey(key, description) { - game.setKey(key, description); +export function setKey(key, description, symbol, help) { + game.setKey(key, description, symbol, help); } -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; +export function renderKeybindings(info) { + const data = JSON.parse(info); + const container = document.getElementById("keybindings-info"); + if (!container) return; + + container.innerHTML = data + .map((entry) => { + const keys = entry.keys.join(", "); + const sym = entry.symbol || keys || ""; + const help = entry.help || ""; + return `
Define an enum and automatically derive key bindings using the #[derive(KeyMap)] macro.
#[derive(keymap::KeyMap, Hash, PartialEq, Eq, Clone)]
+ #[derive(Debug, Clone, keymap::KeyMap, Hash, PartialEq, Eq)]
pub enum Action {
/// Jump over obstacles
- #[key("space")]
+ #[key("space", symbol = "â", help = "jump")] // symbol gets overridden by toml config
Jump,
/// Move leftward
- #[key("left")]
+ #[key("left", help = "move left")]
Left,
/// Move rightward
- #[key("right")]
+ #[key("right", help = "move right")]
Right,
- /// Select a skin (Key Group Capturing!)
- #[key("@digit")]
- SelectSkin(char),
+ /// Pause
+ #[key("p", help = "pause")]
+ Pause,
/// Restart
- #[key("q", "esc")]
+ #[key("q", "esc", help = "quit")]
Quit,
+
+ /// Select a skin
+ #[key("@digit", symbol = "0-9", help = "select skin")]
+ SelectSkin(u8),
}
EXAMPLE: Key Override
Customize or extend key bindings through configuration files without changing the source code.
# Now both 'space' and the 'up' arrow can be used to jump
-Jump = { keys = ["space", "up"], description = "Jump Jump!" }
+Jump = { keys = ["space", "up"], symbol = "âŖ", help = "jump", description = "Jump Jump!" }
diff --git a/examples/wasm/src/main.rs b/examples/wasm/src/main.rs
index 484c2a9..2aa7b19 100644
--- a/examples/wasm/src/main.rs
+++ b/examples/wasm/src/main.rs
@@ -12,45 +12,46 @@ extern "C" {
fn isGameOver() -> bool;
fn resetGame();
fn pauseGame();
- fn setKey(key: String, desc: String);
- fn setSkin(c: char);
+ fn setKey(key: String, desc: String, symbol: String, help: String);
+ fn setSkin(digit: u8);
+ fn renderKeybindings(info: String);
}
#[derive(Debug, Clone, keymap::KeyMap, Hash, PartialEq, Eq)]
pub enum Action {
/// Jump over obstacles
- #[key("space")]
+ #[key("space", symbol = "â", help = "jump")] // symbol gets overridden by toml config
Jump,
/// Move leftward
- #[key("left")]
+ #[key("left", help = "move left")]
Left,
/// Move rightward
- #[key("right")]
+ #[key("right", help = "move right")]
Right,
/// Pause
- #[key("p")]
+ #[key("p", help = "pause")]
Pause,
/// Restart
- #[key("q", "esc")]
+ #[key("q", "esc", help = "quit")]
Quit,
/// Select a skin
- #[key("@digit")]
- SelectSkin(char),
+ #[key("@digit", symbol = "0-9", help = "select skin")]
+ SelectSkin(u8),
}
/// Overrides the default keymap
#[allow(unused)]
pub const DERIVED_CONFIG: &str = r#"
-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" }
+Jump = { keys = ["space", "up"], symbol = "âŖ", help = "jump", description = "Jump Jump!" }
+Quit = { keys = ["q", "esc"], symbol = "âŠ", help = "quit", description = "Quit!" }
+Left = { keys = ["left", "alt-l"], symbol = "â", help = "move left", description = "Move Left" }
+Right = { keys = ["right", "alt-r"], symbol = "â", help = "move right", description = "Move Right" }
+SelectSkin = { keys = ["@digit"], symbol = "0-9", help = "select skin", description = "Select a skin" }
"#;
#[allow(unused)]
@@ -58,6 +59,22 @@ pub fn derived_config() -> DerivedConfig {
toml::from_str(DERIVED_CONFIG).unwrap()
}
+fn json_escape(s: &str) -> String {
+ let mut buf = String::with_capacity(s.len());
+ for c in s.chars() {
+ match c {
+ '"' => buf.push_str("\\\""),
+ '\\' => buf.push_str("\\\\"),
+ '\n' => buf.push_str("\\n"),
+ '\r' => buf.push_str("\\r"),
+ '\t' => buf.push_str("\\t"),
+ c if c.is_control() => buf.push_str(&format!("\\u{:04x}", c as u32)),
+ c => buf.push(c),
+ }
+ }
+ buf
+}
+
#[wasm_bindgen]
pub fn get_keymap_as_json() -> String {
let keymap = derived_config();
@@ -65,13 +82,14 @@ pub fn get_keymap_as_json() -> String {
.items
.iter()
.map(|(action, entry)| {
- let keys: Vec = entry.keys.iter().map(|k| format!("\"{}\"", k)).collect();
- let description = entry.description.clone();
+ let keys: Vec = entry.keys.iter().map(|k| format!("\"{}\"", json_escape(k))).collect();
+ let description = json_escape(&entry.description);
+ let symbol = json_escape(entry.symbol.as_deref().unwrap_or_default());
+ let help = json_escape(entry.help.as_deref().unwrap_or_default());
+ let action_str = json_escape(&format!("{:?}", action));
format!(
- "{{ \"action\": \"{:?}\", \"keys\": [{}], \"description\": \"{}\" }}",
- action,
+ "{{ \"action\": \"{action_str}\", \"keys\": [{}], \"description\": \"{description}\", \"symbol\": \"{symbol}\", \"help\": \"{help}\" }}",
keys.join(","),
- description
)
})
.collect();
@@ -99,6 +117,7 @@ pub fn main() {
on_keydown.forget();
on_keyup.forget();
+ renderKeybindings(get_keymap_as_json());
resetGame();
}
@@ -110,11 +129,19 @@ pub fn handle_key_event(event: &KeyboardEvent, is_keydown: bool) {
if is_keydown {
let key = event.to_keymap().unwrap();
let mut desc = String::new();
+ let mut symbol = String::new();
+ let mut help = String::new();
if let Some((_, item)) = config.get_item(event) {
desc = item.description.clone();
+ if item.keys.iter().any(|k| k.starts_with('@')) {
+ symbol = key.to_string();
+ } else {
+ symbol = item.symbol.clone().unwrap_or_default();
+ }
+ help = item.help.clone().unwrap_or_default();
};
- setKey(key.to_string(), desc);
+ setKey(key.to_string(), desc, symbol, help);
}
// Use .get_bound() to support Key Group Capturing for SelectSkin
@@ -128,7 +155,6 @@ pub fn handle_key_event(event: &KeyboardEvent, is_keydown: bool) {
Action::SelectSkin(c) => {
if is_keydown {
setSkin(c);
- setKey(format!("Skin selected: {c}"), "Key Group Capturing!".to_string());
}
}
_ if !is_game_over => match action {
diff --git a/keymap_derive/src/item.rs b/keymap_derive/src/item.rs
index 590ed9d..27eeebd 100644
--- a/keymap_derive/src/item.rs
+++ b/keymap_derive/src/item.rs
@@ -1,5 +1,5 @@
use keymap_parser::{parse_seq, Node};
-use syn::{punctuated::Punctuated, token::Comma, Attribute, LitStr, Token, Variant};
+use syn::{punctuated::Punctuated, spanned::Spanned, token::Comma, Token, Variant};
/// An attribute path name #[key(...)]
const KEY_IDENT: &str = "key";
@@ -14,18 +14,119 @@ pub(crate) struct Item<'a> {
pub nodes: Vec>,
pub ignore: bool,
pub description: String,
+ pub symbol: Option,
+ pub help: Option,
+}
+
+/// Helper struct representing the arguments parsed from a `#[key(...)]` attribute.
+///
+/// It supports a hybrid syntax:
+/// 1. Positional string literals (e.g. `"ctrl-b"`, `"space"`), which represent the keys to bind.
+/// 2. The `ignore` boolean flag (e.g. `#[key(ignore)]`).
+/// 3. Named name-value fields:
+/// - `symbol = "..."` (e.g. `symbol = "^B"`) defining a custom quick visual symbol for display.
+/// - `help = "..."` (e.g. `help = "jump"`) defining a short help text description for the binding.
+///
+/// Example:
+///
+/// #[key("ctrl-b", symbol = "^B", help = "jump")]
+/// | |______| |__________| |___________|
+/// path keys symbol help
+struct KeyAttrArgs {
+ keys: Vec,
+ ignore: bool,
+ symbol: Option,
+ help: Option,
+}
+
+impl syn::parse::Parse for KeyAttrArgs {
+ fn parse(input: syn::parse::ParseStream) -> syn::Result {
+ let mut keys = Vec::new();
+ let mut ignore = false;
+ let mut symbol = None;
+ let mut help = None;
+
+ while !input.is_empty() {
+ if input.peek(syn::LitStr) {
+ // Parse positional key bindings like "ctrl-b"
+ let lit: syn::LitStr = input.parse()?;
+ keys.push(lit.value());
+ } else if input.peek(syn::Ident) {
+ let ident: syn::Ident = input.parse()?;
+ if ident == "ignore" {
+ // Parse the single 'ignore' flag
+ ignore = true;
+ } else if ident == "symbol" {
+ // Parse 'symbol = "..."'
+ let _: Token![=] = input.parse()?;
+ let lit: syn::LitStr = input.parse()?;
+ symbol = Some(lit.value());
+ } else if ident == "help" {
+ // Parse 'help = "..."'
+ let _: Token![=] = input.parse()?;
+ let lit: syn::LitStr = input.parse()?;
+ help = Some(lit.value());
+ } else {
+ return Err(syn::Error::new(
+ ident.span(),
+ format!("Unknown key attribute argument: {}", ident),
+ ));
+ }
+ } else {
+ return Err(syn::Error::new(
+ input.span(),
+ "Expected string literal or identifier",
+ ));
+ }
+
+ // Consume optional comma separator if there are remaining arguments
+ if !input.is_empty() {
+ let _: Token![,] = input.parse()?;
+ }
+ }
+
+ Ok(KeyAttrArgs {
+ keys,
+ ignore,
+ symbol,
+ help,
+ })
+ }
}
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)?;
+ let mut keys = Vec::new();
+ let mut nodes = Vec::new();
+ let mut ignore = false;
+ let mut symbol = None;
+ let mut help = None;
+
+ for attr in &variant.attrs {
+ if attr.path().is_ident(KEY_IDENT) {
+ let args: KeyAttrArgs = attr.parse_args()?;
+ if args.ignore {
+ ignore = true;
+ }
+ for key in args.keys {
+ let seq = parse_seq(&key).map_err(|e| {
+ syn::Error::new(attr.span(), format!("Invalid key \"{key}\": {e}"))
+ })?;
+ keys.push(key);
+ nodes.push(seq);
+ }
+ if args.symbol.is_some() {
+ symbol = args.symbol;
+ }
+ if args.help.is_some() {
+ help = args.help;
+ }
+ }
+ }
Ok(Item {
variant,
@@ -33,25 +134,13 @@ pub(crate) fn parse_items(
description: parse_doc(variant),
keys,
nodes,
+ symbol,
+ help,
})
})
.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(())
- });
- }
-
- ignore
- })
-}
-
fn parse_doc(variant: &Variant) -> String {
variant
.attrs
@@ -70,39 +159,3 @@ fn parse_doc(variant: &Variant) -> String {
.collect::>()
.join("\n")
}
-
-/// Parse attribute arguments.
-///
-/// Example:
-///
-/// #[key("a", "g g")]
-/// | |________|
-/// path (args)
-fn parse_args(attr: &Attribute) -> syn::Result> {
- attr.parse_args_with(Punctuated::::parse_separated_nonempty)
-}
-
-fn parse_keys(variant: &Variant, ignore: bool) -> syn::Result<(Vec, Vec>)> {
- let mut keys = Vec::new();
- let mut nodes = 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();
- let seq = parse_seq(&val)
- .map_err(|e| syn::Error::new(arg.span(), format!("Invalid key \"{val}\": {e}")))?;
-
- keys.push(val);
- nodes.push(seq);
- }
- }
-
- Ok((keys, nodes))
-}
diff --git a/keymap_derive/src/lib.rs b/keymap_derive/src/lib.rs
index 913508f..594e218 100644
--- a/keymap_derive/src/lib.rs
+++ b/keymap_derive/src/lib.rs
@@ -187,6 +187,15 @@ fn impl_keymap_config(name: &Ident, items: &Vec- ) -> proc_macro2::TokenStre
}
};
+ let symbol_opt = match &item.symbol {
+ Some(sym) => quote! { .with_symbol(Some(#sym)) },
+ None => quote! {},
+ };
+ let help_opt = match &item.help {
+ Some(h) => quote! { .with_help(Some(#h)) },
+ None => quote! {},
+ };
+
match_arms_deserialize.push(quote! {
#variant_name_str => Ok(#variant_expr_default),
});
@@ -194,7 +203,7 @@ fn impl_keymap_config(name: &Ident, items: &Vec
- ) -> proc_macro2::TokenStre
#variant_pat => ::keymap::Item::new(
vec![#(#keys),*],
#doc.to_string()
- ),
+ ) #symbol_opt #help_opt,
});
entries.push(quote! {
@@ -203,7 +212,7 @@ fn impl_keymap_config(name: &Ident, items: &Vec
- ) -> proc_macro2::TokenStre
::keymap::Item::new(
vec![#(#keys),*],
#doc.to_string()
- )
+ ) #symbol_opt #help_opt
),
});
}
diff --git a/keymap_derive/tests/derive.rs b/keymap_derive/tests/derive.rs
index aad5445..379b6a3 100644
--- a/keymap_derive/tests/derive.rs
+++ b/keymap_derive/tests/derive.rs
@@ -44,6 +44,20 @@ enum IgnoreTest {
IgnoredWithData(NoDefault),
}
+#[allow(dead_code)]
+#[derive(Debug, PartialEq, Eq, keymap_derive::KeyMap, Clone)]
+enum CustomSymbolTest {
+ /// Active item with custom symbol and help
+ #[key("ctrl-b", symbol = "^B", help = "jump over obstacles")]
+ Active,
+ /// Active item with help but no symbol (falls back to ctrl-b)
+ #[key("ctrl-b", help = "do jump")]
+ FallbackSymbol,
+ /// Active item with no symbol or help (falls back to ctrl-b)
+ #[key("ctrl-b")]
+ NoSymbolOrHelp,
+}
+
#[cfg(test)]
mod tests {
use keymap_dev::{Error, Item, KeyMap, KeyMapConfig, ToKeyMap};
@@ -213,4 +227,24 @@ mod tests {
.iter()
.any(|(v, _)| matches!(v, IgnoreTest::IgnoredWithData(_))));
}
+
+ #[test]
+ fn test_custom_symbol_and_help() {
+ let config = CustomSymbolTest::keymap_config();
+
+ // 1. symbol and help both specified
+ let (_, item1) = &config.items[0];
+ assert_eq!(item1.symbol.as_deref(), Some("^B"));
+ assert_eq!(item1.help.as_deref(), Some("jump over obstacles"));
+
+ // 2. help specified, symbol omitted (should fallback to "ctrl-b")
+ let (_, item2) = &config.items[1];
+ assert_eq!(item2.symbol.as_deref(), Some("ctrl-b"));
+ assert_eq!(item2.help.as_deref(), Some("do jump"));
+
+ // 3. both symbol and help omitted (should fallback to "ctrl-b" and None)
+ let (_, item3) = &config.items[2];
+ assert_eq!(item3.symbol.as_deref(), Some("ctrl-b"));
+ assert_eq!(item3.help.as_deref(), None);
+ }
}
diff --git a/src/config.rs b/src/config.rs
index 0dce367..5ae0da5 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -240,12 +240,10 @@ impl
Deref for DerivedConfig {
/// # Example
///
/// ```ignore
-/// let item = Item {
-/// keys: vec!["a".into(), "b c".into()],
-/// description: "Some command".into(),
-/// };
+/// let item = Item::new(vec!["a".into(), "b c".into()], "Some command".into());
/// ```
#[derive(Debug, Deserialize, PartialEq)]
+#[serde(from = "ItemRaw")]
pub struct Item {
/// A collection of key expressions. Each expression will be run through
/// `keymap_parser::parse_seq`, so special notations like `@digit` or
@@ -253,8 +251,36 @@ pub struct Item {
pub keys: Vec,
/// A short description for display or documentation purposes.
- #[serde(default)]
pub description: String,
+
+ /// A short key symbol or shortcut representation for the user interface (e.g. `^B`).
+ pub symbol: Option,
+
+ /// A short help description of the binding (e.g. `jump`).
+ pub help: Option,
+}
+
+/// Raw deserialization target â Serde deserializes into this first,
+/// then [`From`] converts it into [`Item`] with fallback logic.
+#[derive(Deserialize)]
+struct ItemRaw {
+ keys: Vec,
+ #[serde(default)]
+ description: String,
+ symbol: Option,
+ help: Option,
+}
+
+impl From for Item {
+ fn from(raw: ItemRaw) -> Self {
+ let symbol = raw.symbol.or_else(|| raw.keys.first().cloned());
+ Self {
+ keys: raw.keys,
+ description: raw.description,
+ symbol,
+ help: raw.help,
+ }
+ }
}
impl Config {
@@ -439,7 +465,7 @@ impl Config {
}
impl Item {
- /// Create a new `Item` with the given list of key expressions and a
+ /// Creates a new `Item` with the given list of key expressions and a
/// description.
///
/// # Parameters
@@ -454,7 +480,25 @@ impl Item {
/// let item = Item::new(vec!["c".into(), "x y".into()], "Some command".into());
/// ```
pub fn new(keys: Vec, description: String) -> Self {
- Self { keys, description }
+ let symbol = keys.first().cloned();
+ Self {
+ keys,
+ description,
+ symbol,
+ help: None,
+ }
+ }
+
+ /// Sets a custom key symbol for display.
+ pub fn with_symbol>(mut self, symbol: Option) -> Self {
+ self.symbol = symbol.map(Into::into);
+ self
+ }
+
+ /// Sets a custom help text.
+ pub fn with_help>(mut self, help: Option) -> Self {
+ self.help = help.map(Into::into);
+ self
}
}
@@ -576,6 +620,12 @@ where
if item.description.is_empty() {
item.description = config.items[pos].1.description.clone();
}
+ if item.help.is_none() {
+ item.help = config.items[pos].1.help.clone();
+ }
+ if item.symbol.is_none() && item.keys == config.items[pos].1.keys {
+ item.symbol = config.items[pos].1.symbol.clone();
+ }
config.items[pos].1 = item;
} else {
// Append a new entry