diff --git a/book/src/remapping.md b/book/src/remapping.md index 23bb80c55..df4a28a72 100644 --- a/book/src/remapping.md +++ b/book/src/remapping.md @@ -41,6 +41,13 @@ g = { a = "code_action" } # Maps `ga` to show possible code actions "ret" = ["open_below", "normal_mode"] # Maps the enter key to open_below then re-enter normal mode "A-x" = "@x" # Maps Alt-x to a macro selecting the whole line and deleting it without yanking it +# You can create labeled sub-menus and provide friendly labels for typeable commands +[keys.normal.space.f] # Registering multiple mappings under a single entry creates a sub-menu (accessed by 'space', 'f' in this case) +label = "File" # The menu is called file and within it: +f = "file_picker" # 'f' opens the file picker +s = { label = "Save", command = ":write" } # 's' saves the current file +c = { label = "Edit Config", command = ":open ~/.config/helix/config.toml" } # 'c' opens the helix config file + [keys.insert] "A-x" = "normal_mode" # Maps Alt-X to enter normal mode j = { k = "normal_mode" } # Maps `jk` to exit insert mode diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index bcba8d8e1..4c2340e91 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -174,6 +174,129 @@ mod tests { ); } + #[test] + fn parsing_menus() { + use crate::keymap; + use helix_core::hashmap; + use helix_view::document::Mode; + + let sample_keymaps = r#" + [keys.normal] + f = { f = "file_picker", c = "wclose" } + b = { label = "buffer", b = "buffer_picker", n = "goto_next_buffer" } + "#; + + let mut keys = keymap::default(); + merge_keys( + &mut keys, + hashmap! { + Mode::Normal => keymap!({ "Normal mode" + "f" => { "" + "f" => file_picker, + "c" => wclose, + }, + "b" => { "buffer" + "b" => buffer_picker, + "n" => goto_next_buffer, + }, + }), + }, + ); + + assert_eq!( + Config::load_test(sample_keymaps), + Config { + keys, + ..Default::default() + } + ); + } + + #[test] + fn parsing_typable_commands() { + use crate::keymap; + use crate::keymap::MappableCommand; + use helix_view::document::Mode; + use helix_view::input::KeyEvent; + use std::str::FromStr; + + let sample_keymaps = r#" + [keys.normal] + o = { label = "Edit Config", command = ":open ~/.config" } + c = ":buffer-close" + h = ["vsplit", "normal_mode", "swap_view_left"] + j = {command = ["hsplit", "normal_mode", "swap_view_down"], label = "split down"} + n = { label = "Delete word", command = "@wd" } + "#; + + let config = Config::load_test(sample_keymaps); + + let tree = config.keys.get(&Mode::Normal).unwrap(); + + if let keymap::KeyTrie::Node(node) = tree { + let open_node = node.get(&KeyEvent::from_str("o").unwrap()).unwrap(); + + if let keymap::KeyTrie::MappableCommand(MappableCommand::Typable { doc, .. }) = + open_node + { + assert_eq!(doc, "Edit Config"); + } else { + panic!("Edit Config did not parse to typable command"); + } + + let close_node = node.get(&KeyEvent::from_str("c").unwrap()).unwrap(); + if let keymap::KeyTrie::MappableCommand(MappableCommand::Typable { doc, .. }) = + close_node + { + assert_eq!(doc, ":buffer-close []"); + } else { + panic!(":buffer-close command did not parse to typable command"); + } + + let split_left = node.get(&KeyEvent::from_str("h").unwrap()).unwrap(); + if let keymap::KeyTrie::Sequence(label, cmds) = split_left { + assert_eq!(label, KeyTrie::DEFAULT_SEQUENCE_LABEL); + assert_eq!( + *cmds, + vec![ + MappableCommand::vsplit, + MappableCommand::normal_mode, + MappableCommand::swap_view_left + ] + ); + } + + let split_down = node.get(&KeyEvent::from_str("j").unwrap()).unwrap(); + if let keymap::KeyTrie::Sequence(label, cmds) = split_down { + assert_eq!(label, "split down"); + assert_eq!( + *cmds, + vec![ + MappableCommand::hsplit, + MappableCommand::normal_mode, + MappableCommand::swap_view_down + ] + ); + } + + let macro_keys = node.get(&KeyEvent::from_str("n").unwrap()).unwrap(); + if let keymap::KeyTrie::MappableCommand(MappableCommand::Macro { name, keys }) = + macro_keys + { + assert_eq!(name, "Delete word"); + assert_eq!( + keys, + &vec![ + KeyEvent::from_str("w").unwrap(), + KeyEvent::from_str("d").unwrap() + ] + ); + } + } else { + panic!("Config did not parse to trie"); + } + } + #[test] fn keys_resolve_to_correct_defaults() { // From serde default diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index d8227b500..014fc11e7 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -12,6 +12,7 @@ use std::{ borrow::Cow, collections::{BTreeSet, HashMap}, ops::{Deref, DerefMut}, + str::FromStr, sync::Arc, }; @@ -83,7 +84,7 @@ impl KeyTrieNode { cmd.doc() } KeyTrie::Node(n) => &n.name, - KeyTrie::Sequence(_) => "[Multiple commands]", + KeyTrie::Sequence(l, ..) => l, }; match body.iter().position(|(_, d)| d == &desc) { Some(pos) => { @@ -133,10 +134,18 @@ impl DerefMut for KeyTrieNode { #[derive(Debug, Clone, PartialEq)] pub enum KeyTrie { MappableCommand(MappableCommand), - Sequence(Vec), + Sequence(String, Vec), Node(KeyTrieNode), } +impl KeyTrie { + pub const DEFAULT_SEQUENCE_LABEL: &'static str = "[Multiple commands]"; + + pub fn sequence(commands: Vec) -> Self { + Self::Sequence(Self::DEFAULT_SEQUENCE_LABEL.to_string(), commands) + } +} + impl<'de> Deserialize<'de> for KeyTrie { fn deserialize(deserializer: D) -> Result where @@ -190,20 +199,99 @@ impl<'de> serde::de::Visitor<'de> for KeyTrieVisitor { )); } - Ok(KeyTrie::Sequence(commands)) + Ok(KeyTrie::Sequence( + KeyTrie::DEFAULT_SEQUENCE_LABEL.to_string(), + commands, + )) } fn visit_map(self, mut map: M) -> Result where M: serde::de::MapAccess<'de>, { + let mut label = String::from(""); + let mut command = None; let mut mapping = HashMap::new(); let mut order = Vec::new(); - while let Some((key, value)) = map.next_entry::()? { - mapping.insert(key, value); - order.push(key); + + while let Some(key) = map.next_key::()? { + match &key as &str { + "label" => label = map.next_value::()?, + "command" => { + command = Some(match map.next_value::()? { + toml::Value::String(s) => { + vec![MappableCommand::from_str(&s).map_err(serde::de::Error::custom)?] + } + toml::Value::Array(arr) => { + let mut vec = Vec::with_capacity(arr.len()); + for value in arr { + let toml::Value::String(s) = value else { + return Err(serde::de::Error::invalid_type( + serde::de::Unexpected::Other(value.type_str()), + &"string", + )); + }; + vec.push( + MappableCommand::from_str(&s) + .map_err(serde::de::Error::custom)?, + ); + } + vec + } + value => { + return Err(serde::de::Error::invalid_type( + serde::de::Unexpected::Other(value.type_str()), + &"string or array", + )) + } + }); + } + _ => { + let key_event = key.parse::().map_err(serde::de::Error::custom)?; + let key_trie = map.next_value::()?; + mapping.insert(key_event, key_trie); + order.push(key_event); + } + } + } + + match command { + None => Ok(KeyTrie::Node(KeyTrieNode::new(label.as_str(), mapping, order))), + Some(_command) if !order.is_empty() => { + Err(serde::de::Error::custom("ambiguous mapping: 'command' is only valid with 'label', but I found other keys")) + } + Some(mut commands) if commands.len() == 1 => match commands.pop() { + None => Err(serde::de::Error::custom("UNREACHABLE!, vec is empty after checking len == 1")), + Some(MappableCommand::Static { .. }) if !label.is_empty() => { + Err(serde::de::Error::custom("custom labels are only available for typable commands (the ones starting with ':')")) + } + Some(MappableCommand::Typable { name, args, .. }) if !label.is_empty() => { + Ok(KeyTrie::MappableCommand(MappableCommand::Typable { + name, + args, + doc: label, + })) + } + + // To label/name macro commands from config + Some(MappableCommand::Macro { keys, .. }) if !label.is_empty() => { + Ok(KeyTrie::MappableCommand(MappableCommand::Macro { + keys, + name: label + })) + } + + Some(command) => Ok(KeyTrie::MappableCommand(command)), + } + Some(commands) => { + let label = if label.is_empty() { + KeyTrie::DEFAULT_SEQUENCE_LABEL.to_string() + } else { + label + }; + Ok(KeyTrie::Sequence(label, commands)) + }, } - Ok(KeyTrie::Node(KeyTrieNode::new("", mapping, order))) } } @@ -226,7 +314,7 @@ impl KeyTrie { keys.pop(); } } - KeyTrie::Sequence(_) => {} + KeyTrie::Sequence(..) => {} }; } @@ -238,14 +326,14 @@ impl KeyTrie { pub fn node(&self) -> Option<&KeyTrieNode> { match *self { KeyTrie::Node(ref node) => Some(node), - KeyTrie::MappableCommand(_) | KeyTrie::Sequence(_) => None, + KeyTrie::MappableCommand(_) | KeyTrie::Sequence(..) => None, } } pub fn node_mut(&mut self) -> Option<&mut KeyTrieNode> { match *self { KeyTrie::Node(ref mut node) => Some(node), - KeyTrie::MappableCommand(_) | KeyTrie::Sequence(_) => None, + KeyTrie::MappableCommand(_) | KeyTrie::Sequence(..) => None, } } @@ -262,7 +350,7 @@ impl KeyTrie { trie = match trie { KeyTrie::Node(map) => map.get(key), // leaf encountered while keys left to process - KeyTrie::MappableCommand(_) | KeyTrie::Sequence(_) => None, + KeyTrie::MappableCommand(_) | KeyTrie::Sequence(..) => None, }? } Some(trie) @@ -352,7 +440,7 @@ impl Keymaps { Some(KeyTrie::MappableCommand(ref cmd)) => { return KeymapResult::Matched(cmd.clone()); } - Some(KeyTrie::Sequence(ref cmds)) => { + Some(KeyTrie::Sequence(_, ref cmds)) => { return KeymapResult::MatchedSequence(cmds.clone()); } None => return KeymapResult::NotFound, @@ -372,7 +460,7 @@ impl Keymaps { self.state.clear(); KeymapResult::Matched(cmd.clone()) } - Some(KeyTrie::Sequence(cmds)) => { + Some(KeyTrie::Sequence(_, cmds)) => { self.state.clear(); KeymapResult::MatchedSequence(cmds.clone()) } @@ -597,7 +685,7 @@ mod tests { let expectation = KeyTrie::Node(KeyTrieNode::new( "", hashmap! { - key => KeyTrie::Sequence(vec!{ + key => KeyTrie::sequence(vec!{ MappableCommand::select_all, MappableCommand::Typable { name: "pipe".to_string(),