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..2dcc3bb 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,9 @@ 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(); + } config.items[pos].1 = item; } else { // Append a new entry