Repository: osa1/tiny (language: rust)

Issue:
Custom key maps
Actions can now be overridden with custom keys

Implementation and usage are fairly straight forward if you look at the `config.yaml`

I won't really be using this, so I tried to make it very simple. Maybe if someone who uses custom key maps has other ideas, then I will add to this. For example, binding a command to a key combo, or adding more combos (`ctrl_shift_x`), etc...

Closes #328 


Discussion / hints:

Fix patch:
diff --git a/crates/libtiny_tui/src/config.rs b/crates/libtiny_tui/src/config.rs
index cf02ef71..ed523b5e 100644
--- a/crates/libtiny_tui/src/config.rs
+++ b/crates/libtiny_tui/src/config.rs
@@ -9,6 +9,8 @@ use std::path::Path;
 
 pub use termbox_simple::*;
 
+use crate::key_map::KeyMap;
+
 #[derive(Deserialize)]
 pub(crate) struct Config {
     #[serde(default)]
@@ -21,6 +23,9 @@ pub(crate) struct Config {
 
     #[serde(default = "default_max_nick_length")]
     pub(crate) max_nick_length: usize,
+
+    #[serde(default)]
+    pub(crate) key_map: Option<KeyMap>,
 }
 
 fn default_max_nick_length() -> usize {
diff --git a/crates/libtiny_tui/src/exit_dialogue.rs b/crates/libtiny_tui/src/exit_dialogue.rs
index adce5e3a..bfc0ad55 100644
--- a/crates/libtiny_tui/src/exit_dialogue.rs
+++ b/crates/libtiny_tui/src/exit_dialogue.rs
@@ -1,6 +1,6 @@
-use term_input::Key;
 use termbox_simple::Termbox;
 
+use crate::key_map::KeyAction;
 use crate::{config::Colors, widget::WidgetRet};
 
 #[derive(Debug)]
@@ -48,9 +48,9 @@ impl ExitDialogue {
         }
     }
 
-    pub(crate) fn keypressed(&self, key: Key) -> WidgetRet {
-        match key {
-            Key::Char('y') | Key::Char('\r') => WidgetRet::Abort,
+    pub(crate) fn keypressed(&self, key_action: KeyAction) -> WidgetRet {
+        match key_action {
+            KeyAction::Input('y') | KeyAction::InputSend => WidgetRet::Abort,
             _ => WidgetRet::Remove,
         }
     }
diff --git a/crates/libtiny_tui/src/input_area/mod.rs b/crates/libtiny_tui/src/input_area/mod.rs
index 8d87505c..8d92f4f8 100644
--- a/crates/libtiny_tui/src/input_area/mod.rs
+++ b/crates/libtiny_tui/src/input_area/mod.rs
@@ -1,10 +1,10 @@
 use std::cmp::{max, min};
 use std::mem;
 
-use term_input::{Arrow, Key};
 use termbox_simple::Termbox;
 
 use crate::config::{Colors, Style};
+use crate::key_map::KeyAction;
 use crate::msg_area::MsgArea;
 use crate::termbox;
 use crate::trie::Trie;
@@ -215,9 +215,9 @@ impl InputArea {
         }
     }
 
-    pub(crate) fn keypressed(&mut self, key: Key) -> WidgetRet {
-        match key {
-            Key::Char('\r') => {
+    pub(crate) fn keypressed(&mut self, key_action: KeyAction) -> WidgetRet {
+        match key_action {
+            KeyAction::InputSend => {
                 if self.current_buffer_len() > 0 {
                     self.modify();
 
@@ -236,15 +236,7 @@ impl InputArea {
                     WidgetRet::KeyHandled
                 }
             }
-
-            Key::Char(ch) => {
-                self.modify();
-                self.buffer.insert(self.cursor as usize, ch);
-                self.inc_cursor();
-                WidgetRet::KeyHandled
-            }
-
-            Key::Backspace => {
+            KeyAction::InputDeletePrevChar => {
                 if self.cursor > 0 {
                     self.modify();
                     self.buffer.remove(self.cursor as usize - 1);
@@ -252,8 +244,7 @@ impl InputArea {
                 }
                 WidgetRet::KeyHandled
             }
-
-            Key::Del => {
+            KeyAction::InputDeleteNextChar => {
                 if self.cursor < self.current_buffer_len() {
                     self.modify();
                     self.buffer.remove(self.cursor as usize);
@@ -261,39 +252,34 @@ impl InputArea {
                 }
                 WidgetRet::KeyHandled
             }
-
-            Key::Ctrl(ch) => {
-                if ch == 'a' {
-                    self.move_cursor(0);
-                    WidgetRet::KeyHandled
-                } else if ch == 'e' {
-                    self.move_cursor_to_end();
-                    WidgetRet::KeyHandled
-                } else if ch == 'k' {
-                    if self.cursor != self.current_buffer_len() {
-                        self.modify();
-                        self.buffer.drain(self.cursor as usize..);
-                    }
-                    WidgetRet::KeyHandled
-                } else if ch == 'w' {
-                    self.consume_word_before_curs();
-                    WidgetRet::KeyHandled
-                } else {
-                    WidgetRet::KeyIgnored
+            KeyAction::InputMoveCursStart => {
+                self.move_cursor(0);
+                WidgetRet::KeyHandled
+            }
+            KeyAction::InputMoveCursEnd => {
+                self.move_cursor_to_end();
+                WidgetRet::KeyHandled
+            }
+            KeyAction::InputDeleteToEnd => {
+                if self.cursor != self.current_buffer_len() {
+                    self.modify();
+                    self.buffer.drain(self.cursor as usize..);
                 }
+                WidgetRet::KeyHandled
             }
-
-            Key::Arrow(Arrow::Left) => {
+            KeyAction::InputDeletePrevWord => {
+                self.consume_word_before_curs();
+                WidgetRet::KeyHandled
+            }
+            KeyAction::InputMoveCursLeft => {
                 self.dec_cursor();
                 WidgetRet::KeyHandled
             }
-
-            Key::Arrow(Arrow::Right) => {
+            KeyAction::InputMoveCursRight => {
                 self.inc_cursor();
                 WidgetRet::KeyHandled
             }
-
-            Key::CtrlArrow(Arrow::Left) => {
+            KeyAction::InputMoveWordLeft => {
                 if self.cursor > 0 {
                     let mut cur = self.cursor as usize;
                     let mut skipped = false;
@@ -312,8 +298,7 @@ impl InputArea {
                 }
                 WidgetRet::KeyHandled
             }
-
-            Key::CtrlArrow(Arrow::Right) => {
+            KeyAction::InputMoveWordRight => {
                 let len = self.current_buffer_len() as usize;
                 if (self.cursor as usize) < len {
                     let mut cur = self.cursor as usize;
@@ -333,109 +318,20 @@ impl InputArea {
                 }
                 WidgetRet::KeyHandled
             }
-
-            ////////////////////////////////////////////////////////////////////
-            // Scrolling in history or autocompletion list
-            Key::Arrow(Arrow::Up) => {
-                // invalidate height calculation
-                self.height = None;
-                let mode = mem::replace(&mut self.mode, Mode::Edit);
-
-                match mode {
-                    Mode::Edit => {
-                        if !self.history.is_empty() {
-                            self.mode = Mode::History((self.history.len() as i32) - 1);
-                            self.move_cursor_to_end();
-                        }
-                    }
-                    Mode::History(hist_curs) => {
-                        self.mode = Mode::History(if hist_curs > 0 {
-                            hist_curs - 1
-                        } else {
-                            hist_curs
-                        });
-                        self.move_cursor_to_end();
-                    }
-                    Mode::Autocomplete {
-                        original_buffer,
-                        insertion_point,
-                        word_starts,
-                        completions,
-                        current_completion,
-                        ..
-                    } => {
-                        let current_completion = if current_completion == completions.len() - 1 {
-                            0
-                        } else {
-                            current_completion + 1
-                        };
-
-                        let cursor =
-                            (insertion_point + completions[current_completion].len()) as i32;
-
-                        self.mode = Mode::Autocomplete {
-                            original_buffer,
-                            insertion_point,
-                            word_starts,
-                            completions,
-                            current_completion,
-                        };
-
-                        self.move_cursor(cursor);
-                    }
-                }
-
+            KeyAction::InputPrevEntry => {
+                self.completion_prev_entry();
                 WidgetRet::KeyHandled
             }
-
-            Key::Arrow(Arrow::Down) => {
-                // invalidate height calculation
-                self.height = None;
-                let mode = mem::replace(&mut self.mode, Mode::Edit);
-
-                match mode {
-                    Mode::Edit => {}
-                    Mode::History(hist_curs) => {
-                        if hist_curs != (self.history.len() - 1) as i32 {
-                            self.mode = Mode::History(hist_curs + 1);
-                        } else {
-                            self.mode = Mode::Edit;
-                        }
-                        self.move_cursor_to_end();
-                    }
-                    Mode::Autocomplete {
-                        original_buffer,
-                        insertion_point,
-                        word_starts,
-                        completions,
-                        current_completion,
-                        ..
-                    } => {
-                        let current_completion = if current_completion == 0 {
-                            completions.len() - 1
-                        } else {
-                            current_completion - 1
-                        };
-
-                        let cursor =
-                            (insertion_point + completions[current_completion].len()) as i32;
-
-                        self.mode = Mode::Autocomplete {
-                            original_buffer,
-                            insertion_point,
-                            word_starts,
-                            completions,
-                            current_completion,
-                        };
-
-                        self.move_cursor(cursor);
-                    }
-                }
-
+            KeyAction::InputNextEntry => {
+                self.completion_next_entry();
+                WidgetRet::KeyHandled
+            }
+            KeyAction::Input(ch) => {
+                self.modify();
+                self.buffer.insert(self.cursor as usize, ch);
+                self.inc_cursor();
                 WidgetRet::KeyHandled
             }
-
-            ////////////////////////////////////////////////////////////////////
             _ => WidgetRet::KeyIgnored,
         }
     }
@@ -503,6 +399,99 @@ impl InputArea {
         self.move_cursor(begin_range + 1);
     }
 
+    fn completion_prev_entry(&mut self) {
+        // invalidate height calculation
+        self.height = None;
+        let mode = mem::replace(&mut self.mode, Mode::Edit);
+
+        match mode {
+            Mode::Edit => {
+                if !self.history.is_empty() {
+                    self.mode = Mode::History((self.history.len() as i32) - 1);
+                    self.move_cursor_to_end();
+                }
+            }
+            Mode::History(hist_curs) => {
+                self.mode = Mode::History(if hist_curs > 0 {
+                    hist_curs - 1
+                } else {
+                    hist_curs
+                });
+                self.move_cursor_to_end();
+            }
+            Mode::Autocomplete {
+                original_buffer,
+                insertion_point,
+                word_starts,
+                completions,
+                current_completion,
+                ..
+            } => {
+                let current_completion = if current_completion == completions.len() - 1 {
+                    0
+                } else {
+                    current_completion + 1
+                };
+
+                let cursor = (insertion_point + completions[current_completion].len()) as i32;
+
+                self.mode = Mode::Autocomplete {
+                    original_buffer,
+                    insertion_point,
+                    word_starts,
+                    completions,
+                    current_completion,
+                };
+
+                self.move_cursor(cursor);
+            }
+        }
+    }
+
+    fn completion_next_entry(&mut self) {
+        // invalidate height calculation
+        self.height = None;
+        let mode = mem::replace(&mut self.mode, Mode::Edit);
+
+        match mode {
+            Mode::Edit => {}
+            Mode::History(hist_curs) => {
+                if hist_curs != (self.history.len() - 1) as i32 {
+                    self.mode = Mode::History(hist_curs + 1);
+                } else {
+                    self.mode = Mode::Edit;
+                }
+                self.move_cursor_to_end();
+            }
+            Mode::Autocomplete {
+                original_buffer,
+                insertion_point,
+                word_starts,
+                completions,
+                current_completion,
+                ..
+            } => {
+                let current_completion = if current_completion == 0 {
+                    completions.len() - 1
+                } else {
+                    current_completion - 1
+                };
+
+                let cursor = (insertion_point + completions[current_completion].len()) as i32;
+
+                self.mode = Mode::Autocomplete {
+                    original_buffer,
+                    insertion_point,
+                    word_starts,
+                    completions,
+                    current_completion,
+                };
+
+                self.move_cursor(cursor);
+            }
+        }
+    }
+
     // Ignoring auto-completions
     pub(crate) fn shown_line(&mut self) -> &mut InputLine {
         match &mut self.mode {
@@ -722,8 +711,8 @@ impl InputArea {
 impl InputArea {
     pub(crate) fn autocomplete(&mut self, dict: &Trie) {
         if self.in_autocomplete() {
-            // AWFUL CODE YO
-            self.keypressed(Key::Arrow(Arrow::Up));
+            // scroll next if you hit the KeyAction::InputAutoComplete key again
+            self.completion_prev_entry();
             return;
         }
 
@@ -782,23 +771,22 @@ impl InputArea {
 mod tests {
 
     use super::*;
-    use term_input::{Arrow, Key};
 
     #[test]
     fn text_field_bug() {
         let mut text_field = InputArea::new(10, 50);
-        text_field.keypressed(Key::Char('a'));
-        text_field.keypressed(Key::Char(' '));
-        text_field.keypressed(Key::Char('b'));
-        text_field.keypressed(Key::Char(' '));
-        text_field.keypressed(Key::Char('c'));
-        text_field.keypressed(Key::Char('\r'));
-        text_field.keypressed(Key::Arrow(Arrow::Up));
+        text_field.keypressed(KeyAction::Input('a'));
+        text_field.keypressed(KeyAction::Input(' '));
+        text_field.keypressed(KeyAction::Input('b'));
+        text_field.keypressed(KeyAction::Input(' '));
+        text_field.keypressed(KeyAction::Input('c'));
+        text_field.keypressed(KeyAction::InputSend);
+        text_field.keypressed(KeyAction::InputPrevEntry);
         // this panics:
-        text_field.keypressed(Key::CtrlArrow(Arrow::Left));
+        text_field.keypressed(KeyAction::InputMoveWordLeft);
         // a b ^c
         assert_eq!(text_field.cursor, 4);
-        text_field.keypressed(Key::CtrlArrow(Arrow::Right));
+        text_field.keypressed(KeyAction::InputMoveWordRight);
         assert_eq!(text_field.cursor, 5);
     }
 
diff --git a/crates/libtiny_tui/src/key_map.rs b/crates/libtiny_tui/src/key_map.rs
new file mode 100644
index 00000000..e4c931a4
--- /dev/null
+++ b/crates/libtiny_tui/src/key_map.rs
@@ -0,0 +1,313 @@
+use std::collections::HashMap;
+use std::fmt::Display;
+
+use serde::de::{MapAccess, Unexpected, Visitor};
+use serde::{Deserialize, Deserializer};
+use term_input::{Arrow, Key};
+
+#[derive(Debug, PartialEq)]
+pub(crate) struct KeyMap(HashMap<Key, KeyAction>);
+
+#[derive(Debug, Copy, Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "snake_case")]
+pub(crate) enum KeyAction {
+    Cancel,
+    Disable,
+    Exit,
+
+    RunEditor,
+
+    TabNext,
+    TabPrev,
+    TabMoveLeft,
+    TabMoveRight,
+    TabGoto(char),
+
+    MessagesPageUp,
+    MessagesPageDown,
+    MessagesScrollUp,
+    MessagesScrollDown,
+    MessagesScrollTop,
+    MessagesScrollBottom,
+
+    Input(char),
+    InputAutoComplete,
+    InputNextEntry,
+    InputPrevEntry,
+    InputSend,
+    InputDeletePrevChar,
+    InputDeleteNextChar,
+    InputDeleteToEnd,
+    InputDeletePrevWord,
+    InputMoveCursEnd,
+    InputMoveCursStart,
+    InputMoveCursLeft,
+    InputMoveCursRight,
+    InputMoveWordLeft,
+    InputMoveWordRight,
+}
+
+impl Display for KeyAction {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let s = match self {
+            KeyAction::Cancel => "cancel",
+            KeyAction::Disable => "disable",
+            KeyAction::Exit => "exit",
+            KeyAction::RunEditor => "run_editor",
+            KeyAction::TabNext => "tab_next",
+            KeyAction::TabPrev => "tab_prev",
+            KeyAction::TabMoveLeft => "tab_move_left",
+            KeyAction::TabMoveRight => "tab_move_right",
+            KeyAction::TabGoto(c) => return writeln!(f, "tab_goto: {}", c),
+            KeyAction::MessagesPageUp => "messages_page_up",
+            KeyAction::MessagesPageDown => "messages_page_down",
+            KeyAction::MessagesScrollUp => "messages_scroll_up",
+            KeyAction::MessagesScrollDown => "messages_scroll_down",
+            KeyAction::MessagesScrollTop => "messages_scroll_top",
+            KeyAction::MessagesScrollBottom => "messages_scroll_bottom",
+            KeyAction::Input(c) => return writeln!(f, "input_{}", c),
+            KeyAction::InputAutoComplete => "input_auto_complete",
+            KeyAction::InputNextEntry => "input_next_entry",
+            KeyAction::InputPrevEntry => "input_prev_entry",
+            KeyAction::InputSend => "input_send",
+            KeyAction::InputDeletePrevChar => "input_delete_prev_char",
+            KeyAction::InputDeleteNextChar => "input_delete_next_char",
+            KeyAction::InputDeleteToEnd => "input_delete_to_end",
+            KeyAction::InputDeletePrevWord => "input_delete_prev_word",
+            KeyAction::InputMoveCursEnd => "input_move_curs_end",
+            KeyAction::InputMoveCursStart => "input_move_curs_start",
+            KeyAction::InputMoveCursLeft => "input_move_curs_left",
+            KeyAction::InputMoveCursRight => "input_move_curs_right",
+            KeyAction::InputMoveWordLeft => "input_move_word_left",
+            KeyAction::InputMoveWordRight => "input_move_word_right",
+        };
+        writeln!(f, "{}", s)
+    }
+}
+
+impl Default for KeyMap {
+    fn default() -> Self {
+        let map = vec![
+            (Key::Esc, KeyAction::Cancel),
+            (Key::Ctrl('c'), KeyAction::Exit),
+            (Key::Ctrl('x'), KeyAction::RunEditor),
+            (Key::Ctrl('n'), KeyAction::TabNext),
+            (Key::Ctrl('p'), KeyAction::TabPrev),
+            (Key::CtrlArrow(Arrow::Left), KeyAction::TabMoveLeft),
+            (Key::CtrlArrow(Arrow::Left), KeyAction::TabMoveRight),
+            (Key::AltChar('1'), KeyAction::TabGoto('1')),
+            (Key::AltChar('2'), KeyAction::TabGoto('2')),
+            (Key::AltChar('3'), KeyAction::TabGoto('3')),
+            (Key::AltChar('4'), KeyAction::TabGoto('4')),
+            (Key::AltChar('5'), KeyAction::TabGoto('5')),
+            (Key::AltChar('6'), KeyAction::TabGoto('6')),
+            (Key::AltChar('7'), KeyAction::TabGoto('7')),
+            (Key::AltChar('8'), KeyAction::TabGoto('8')),
+            (Key::AltChar('9'), KeyAction::TabGoto('9')),
+            (Key::AltChar('0'), KeyAction::TabGoto('0')),
+            (Key::Ctrl('u'), KeyAction::MessagesPageUp),
+            (Key::Ctrl('d'), KeyAction::MessagesPageDown),
+            (Key::PageUp, KeyAction::MessagesPageUp),
+            (Key::PageDown, KeyAction::MessagesPageDown),
+            (Key::ShiftUp, KeyAction::MessagesScrollUp),
+            (Key::ShiftDown, KeyAction::MessagesScrollDown),
+            (Key::Home, KeyAction::MessagesScrollTop),
+            (Key::End, KeyAction::MessagesScrollBottom),
+            (Key::Tab, KeyAction::InputAutoComplete),
+            (Key::Arrow(Arrow::Up), KeyAction::InputPrevEntry),
+            (Key::Arrow(Arrow::Down), KeyAction::InputNextEntry),
+            (Key::Char('\r'), KeyAction::InputSend),
+            (Key::Backspace, KeyAction::InputDeletePrevChar),
+            (Key::Del, KeyAction::InputDeleteNextChar),
+            (Key::Ctrl('a'), KeyAction::InputMoveCursStart),
+            (Key::Ctrl('e'), KeyAction::InputMoveCursEnd),
+            (Key::Ctrl('k'), KeyAction::InputDeleteToEnd),
+            (Key::Ctrl('w'), KeyAction::InputDeletePrevWord),
+            (Key::Arrow(Arrow::Left), KeyAction::InputMoveCursLeft),
+            (Key::Arrow(Arrow::Right), KeyAction::InputMoveCursRight),
+            (Key::CtrlArrow(Arrow::Left), KeyAction::InputMoveWordLeft),
+            (Key::CtrlArrow(Arrow::Right), KeyAction::InputMoveWordRight),
+        ];
+        let hash_map = map.into_iter().collect::<HashMap<_, _>>();
+        KeyMap(hash_map)
+    }
+}
+
+impl Display for KeyMap {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        for (key, action) in self.0.iter() {
+            writeln!(f, "key: {:?}, action: {}", key, action)?;
+        }
+        Ok(())
+    }
+}
+
+impl KeyMap {
+    pub(crate) fn get(&self, key: &Key) -> Option<KeyAction> {
+        self.0.get(key).cloned()
+    }
+
+    pub(crate) fn load(&mut self, key_map: &KeyMap) {
+        self.0.extend(key_map.0.iter())
+    }
+}
+
+impl<'de> Deserialize<'de> for KeyMap {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        struct KeyMapVisitor;
+
+        impl<'de> Visitor<'de> for KeyMapVisitor {
+            type Value = KeyMap;
+
+            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+                write!(
+                    formatter,
+                    "a map of key bindings. ex. 'ctrl_a: input_move_curs_start'"
+                )?;
+                write!(formatter, "defaults: \n{}", KeyMap::default())
+            }
+
+            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
+            where
+                A: MapAccess<'de>,
+            {
+                let mut key_map = HashMap::new();
+                while let Some((key, action)) = map.next_entry::<MappedKey, KeyAction>()? {
+                    key_map.insert(key.0, action);
+                }
+                Ok(KeyMap(key_map))
+            }
+        }
+        deserializer.deserialize_map(KeyMapVisitor)
+    }
+}
+
+#[derive(Debug)]
+pub(crate) struct MappedKey(Key);
+
+impl<'de> Deserialize<'de> for MappedKey {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        struct MappedKeyVisitor;
+
+        impl<'de> Visitor<'de> for MappedKeyVisitor {
+            type Value = MappedKey;
+
+            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+                write!(formatter, "single keys: backspace, del, end, esc, home, pgdown, pgup, tab, up, down, left right, [a-z], [0-9]. ")?;
+                write!(
+                    formatter,
+                    "modifiers with arrow key or single characters:  alt, shift, ctrl"
+                )
+            }
+
+            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+            where
+                E: serde::de::Error,
+            {
+                let key_combo = v.split_once('_');
+                let single_key = |s: &str| {
+                    let mut chars = s.chars();
+                    if let (Some(c), None) = (chars.next(), chars.next()) {
+                        Ok(c)
+                    } else {
+                        Err(E::invalid_value(Unexpected::Str(s), &Self))
+                    }
+                };
+
+                let key = match key_combo {
+                    None => match v {
+                        "backspace" => Key::Backspace,
+                        "del" => Key::Del,
+                        "end" => Key::End,
+                        "esc" => Key::Esc,
+                        "home" => Key::Home,
+                        "pgdown" => Key::PageDown,
+                        "pgup" => Key::PageUp,
+                        "tab" => Key::Tab,
+                        "up" => Key::Arrow(Arrow::Up),
+                        "down" => Key::Arrow(Arrow::Down),
+                        "left" => Key::Arrow(Arrow::Left),
+                        "right" => Key::Arrow(Arrow::Right),
+                        ch => Key::Char(single_key(ch)?),
+                    },
+                    Some((k1, k2)) => match k1 {
+                        "alt" => match k2 {
+                            "up" => Key::AltArrow(Arrow::Up),
+                            "down" => Key::AltArrow(Arrow::Down),
+                            "left" => Key::AltArrow(Arrow::Left),
+                            "right" => Key::AltArrow(Arrow::Right),
+                            ch => Key::AltChar(single_key(ch)?),
+                        },
+                        "ctrl" => match k2 {
+                            "up" => Key::CtrlArrow(Arrow::Up),
+                            "down" => Key::CtrlArrow(Arrow::Down),
+                            "left" => Key::CtrlArrow(Arrow::Left),
+                            "right" => Key::CtrlArrow(Arrow::Right),
+                            ch => Key::Ctrl(single_key(ch)?),
+                        },
+                        "shift" => match k2 {
+                            "up" => Key::ShiftUp,
+                            "down" => Key::ShiftDown,
+                            unexp => return Err(E::invalid_value(Unexpected::Str(unexp), &Self)),
+                        },
+                        unexp => return Err(E::invalid_value(Unexpected::Str(unexp), &Self)),
+                    },
+                };
+
+                if term_input::is_valid_key(key) {
+                    Ok(MappedKey(key))
+                } else {
+                    Err(E::invalid_value(Unexpected::Str(v), &Self))
+                }
+            }
+        }
+
+        deserializer.deserialize_str(MappedKeyVisitor)
+    }
+}
+
+#[test]
+fn deser_key() {
+    let s = "alt_p";
+    let key: MappedKey = serde_yaml::from_str(s).unwrap();
+    assert_eq!(Key::AltChar('p'), key.0);
+    let s = "alt_è";
+    let key: MappedKey = serde_yaml::from_str(s).unwrap();
+    assert_eq!(Key::AltChar('è'), key.0);
+    let s = "alt__";
+    let key: MappedKey = serde_yaml::from_str(s).unwrap();
+    assert_eq!(Key::AltChar('_'), key.0);
+}
+
+#[test]
+fn deser_key_action_goto_tab() {
+    let s = "tab_goto: 1";
+    let a = serde_yaml::from_str::<KeyAction>(s).unwrap();
+    assert_eq!(KeyAction::TabGoto('1'), a);
+}
+
+#[test]
+fn deser_keymap() {
+    let s = r#"
+    ctrl_a: input_move_curs_start
+    ctrl_e: 
+      tab_goto: 1
+    a: 
+      input: b
+    "#;
+    let mut expect = KeyMap(HashMap::new());
+    expect
+        .0
+        .insert(Key::Ctrl('a'), KeyAction::InputMoveCursStart);
+    expect.0.insert(Key::Ctrl('e'), KeyAction::TabGoto('1'));
+    expect.0.insert(Key::Char('a'), KeyAction::Input('b'));
+
+    let key_map: KeyMap = serde_yaml::from_str(s).unwrap();
+    assert_eq!(expect, key_map);
+}
diff --git a/crates/libtiny_tui/src/lib.rs b/crates/libtiny_tui/src/lib.rs
index a1c1a7e1..404f6027 100644
--- a/crates/libtiny_tui/src/lib.rs
+++ b/crates/libtiny_tui/src/lib.rs
@@ -5,6 +5,7 @@ mod config;
 mod editor;
 mod exit_dialogue;
 mod input_area;
+mod key_map;
 mod line_split;
 mod messaging;
 #[doc(hidden)]
diff --git a/crates/libtiny_tui/src/messaging.rs b/crates/libtiny_tui/src/messaging.rs
index 8e9548f6..04bf5538 100644
--- a/crates/libtiny_tui/src/messaging.rs
+++ b/crates/libtiny_tui/src/messaging.rs
@@ -1,4 +1,3 @@
-use term_input::Key;
 use termbox_simple::Termbox;
 
 use std::convert::From;
@@ -8,6 +7,7 @@ use time::{self, Tm};
 use crate::config::Colors;
 use crate::exit_dialogue::ExitDialogue;
 use crate::input_area::InputArea;
+use crate::key_map::KeyAction;
 use crate::msg_area::line::SegStyle;
 use crate::msg_area::{Layout, MsgArea};
 use crate::trie::Trie;
@@ -135,56 +135,48 @@ impl MessagingUI {
         self.msg_area.draw(tb, colors, pos_x, pos_y);
     }
 
-    pub(crate) fn keypressed(&mut self, key: Key) -> WidgetRet {
-        match key {
-            Key::Ctrl('c') => {
+    pub(crate) fn keypressed(&mut self, key_action: KeyAction) -> WidgetRet {
+        match key_action {
+            KeyAction::Exit => {
                 self.toggle_exit_dialogue();
                 WidgetRet::KeyHandled
             }
-
-            Key::Ctrl('u') | Key::PageUp => {
+            KeyAction::MessagesPageUp => {
                 self.msg_area.page_up();
                 WidgetRet::KeyHandled
             }
-
-            Key::Ctrl('d') | Key::PageDown => {
+            KeyAction::MessagesPageDown => {
                 self.msg_area.page_down();
                 WidgetRet::KeyHandled
             }
-
-            Key::ShiftUp => {
+            KeyAction::MessagesScrollUp => {
                 self.msg_area.scroll_up();
                 WidgetRet::KeyHandled
             }
-
-            Key::ShiftDown => {
+            KeyAction::MessagesScrollDown => {
                 self.msg_area.scroll_down();
                 WidgetRet::KeyHandled
             }
-
-            Key::Tab => {
-                if self.exit_dialogue.is_none() {
-                    self.input_field.autocomplete(&self.nicks);
-                }
-                WidgetRet::KeyHandled
-            }
-
-            Key::Home => {
+            KeyAction::MessagesScrollTop => {
                 self.msg_area.scroll_top();
                 WidgetRet::KeyHandled
             }
-
-            Key::End => {
+            KeyAction::MessagesScrollBottom => {
                 self.msg_area.scroll_bottom();
                 WidgetRet::KeyHandled
             }
-
-            key => {
+            KeyAction::InputAutoComplete => {
+                if self.exit_dialogue.is_none() {
+                    self.input_field.autocomplete(&self.nicks);
+                }
+                WidgetRet::KeyHandled
+            }
+            key_action => {
                 let ret = {
                     if let Some(exit_dialogue) = self.exit_dialogue.as_ref() {
-                        exit_dialogue.keypressed(key)
+                        exit_dialogue.keypressed(key_action)
                     } else {
-                        self.input_field.keypressed(key)
+                        self.input_field.keypressed(key_action)
                     }
                 };
 
diff --git a/crates/libtiny_tui/src/tui.rs b/crates/libtiny_tui/src/tui.rs
index e63098f8..80190bdd 100644
--- a/crates/libtiny_tui/src/tui.rs
+++ b/crates/libtiny_tui/src/tui.rs
@@ -4,12 +4,14 @@
 
 use std::borrow::Borrow;
 use std::cmp::Ordering;
+use std::collections::HashMap;
 use std::path::PathBuf;
 use std::str::{self, SplitWhitespace};
 use time::Tm;
 
 use crate::config::{parse_config, Colors, Config, Style};
 use crate::editor;
+use crate::key_map::{KeyAction, KeyMap};
 use crate::messaging::{MessagingUI, Timestamp};
 use crate::msg_area::Layout;
 use crate::notifier::Notifier;
@@ -17,7 +19,7 @@ use crate::tab::Tab;
 use crate::widget::WidgetRet;
 
 use libtiny_common::{ChanNameRef, MsgSource, MsgTarget, TabStyle};
-use term_input::{Arrow, Event, Key};
+use term_input::{Event, Key};
 pub use termbox_simple::{CellBuf, Termbox};
 
 #[derive(Debug)]
@@ -89,6 +91,8 @@ pub struct TUI {
     height: i32,
     h_scroll: i32,
 
+    key_map: KeyMap,
+
     /// Config file path
     config_path: Option<PathBuf>,
 }
@@ -146,6 +150,7 @@ impl TUI {
             width,
             height,
             h_scroll: 0,
+            key_map: KeyMap::default(),
             config_path,
         };
 
@@ -297,9 +302,11 @@ impl TUI {
                     scrollback,
                     layout,
                     max_nick_length,
+                    key_map,
                 }) => {
                     self.set_colors(colors);
                     self.scrollback = scrollback.max(1);
+                    self.key_map.load(&key_map.unwrap_or_default());
                     if let Some(layout) = layout {
                         match layout {
                             crate::config::Layout::Compact => self.msg_layout = Layout::Compact,
@@ -329,8 +336,6 @@ impl TUI {
         notifier: Notifier,
         alias: Option<String>,
     ) {
-        use std::collections::HashMap;
-
         let mut switch_keys: HashMap<char, i8> = HashMap::with_capacity(self.tabs.len());
         for tab in &self.tabs {
             if let Some(key) = tab.switch {
@@ -619,87 +624,57 @@ impl TUI {
         key: Key,
         rcv_editor_ret: &mut Option<editor::ResultReceiver>,
     ) -> TUIRet {
-        match self.tabs[self.active_idx].widget.keypressed(key) {
-            WidgetRet::KeyHandled => TUIRet::KeyHandled,
-            WidgetRet::KeyIgnored => self.handle_keypress(key, rcv_editor_ret),
-            WidgetRet::Input(input) => TUIRet::Input {
-                msg: input,
-                from: self.tabs[self.active_idx].src.clone(),
-            },
-            WidgetRet::Remove => unimplemented!(),
-            WidgetRet::Abort => TUIRet::Abort,
+        let key_action = self.key_map.get(&key).or_else(|| match key {
+            Key::Char(c) => Some(KeyAction::Input(c)),
+            Key::AltChar(c) => Some(KeyAction::TabGoto(c)),
+            _ => None,
+        });
+
+        if let Some(key_action) = key_action {
+            match self.tabs[self.active_idx].widget.keypressed(key_action) {
+                WidgetRet::KeyHandled => TUIRet::KeyHandled,
+                WidgetRet::KeyIgnored => self.handle_keypress(key, key_action, rcv_editor_ret),
+                WidgetRet::Input(input) => TUIRet::Input {
+                    msg: input,
+                    from: self.tabs[self.active_idx].src.clone(),
+                },
+                WidgetRet::Remove => unimplemented!(),
+                WidgetRet::Abort => TUIRet::Abort,
+            }
+        } else {
+            TUIRet::KeyIgnored(key)
         }
     }
 
     fn handle_keypress(
         &mut self,
         key: Key,
+        key_action: KeyAction,
         rcv_editor_ret: &mut Option<editor::ResultReceiver>,
     ) -> TUIRet {
-        match key {
-            Key::Ctrl('n') => {
-                self.next_tab();
+        match key_action {
+            KeyAction::RunEditor => {
+                self.run_editor("", rcv_editor_ret);
                 TUIRet::KeyHandled
             }
-
-            Key::Ctrl('p') => {
-                self.prev_tab();
+            KeyAction::TabNext => {
+                self.next_tab();
                 TUIRet::KeyHandled
             }
-
-            Key::Ctrl('x') => {
-                self.run_editor("", rcv_editor_ret);
+            KeyAction::TabPrev => {
+                self.prev_tab();
                 TUIRet::KeyHandled
             }
-
-            Key::AltChar(c) => match c.to_digit(10) {
-                Some(i) => {
-                    let new_tab_idx: usize = if i as usize > self.tabs.len() || i == 0 {
-                        self.tabs.len() - 1
-                    } else {
-                        i as usize - 1
-                    };
-                    match new_tab_idx.cmp(&self.active_idx) {
-                        Ordering::Greater => {
-                            for _ in 0..new_tab_idx - self.active_idx {
-                                self.next_tab_();
-                            }
-                        }
-                        Ordering::Less => {
-                            for _ in 0..self.active_idx - new_tab_idx {
-                                self.prev_tab_();
-                            }
-                        }
-                        Ordering::Equal => {}
-                    }
-                    self.tabs[self.active_idx].set_style(TabStyle::Normal);
-                    TUIRet::KeyHandled
-                }
-                None => {
-                    // multiple tabs can have same switch character so scan
-                    // forwards instead of starting from the first tab
-                    for i in 1..=self.tabs.len() {
-                        let idx = (self.active_idx + i) % self.tabs.len();
-                        if self.tabs[idx].switch == Some(c) {
-                            self.select_tab(idx);
-                            break;
-                        }
-                    }
-                    TUIRet::KeyHandled
-                }
-            },
-
-            Key::AltArrow(Arrow::Left) => {
+            KeyAction::TabMoveLeft => {
                 self.move_tab_left();
                 TUIRet::KeyHandled
             }
-
-            Key::AltArrow(Arrow::Right) => {
+            KeyAction::TabMoveRight => {
                 self.move_tab_right();
                 TUIRet::KeyHandled
             }
-
-            key => TUIRet::KeyIgnored(key),
+            KeyAction::TabGoto(c) => self.go_to_tab(c),
+            _ => TUIRet::KeyIgnored(key),
         }
     }
 
@@ -1107,6 +1082,45 @@ impl TUI {
         }
     }
 
+    fn go_to_tab(&mut self, c: char) -> TUIRet {
+        match c.to_digit(10) {
+            Some(i) => {
+                let new_tab_idx: usize = if i as usize > self.tabs.len() || i == 0 {
+                    self.tabs.len() - 1
+                } else {
+                    i as usize - 1
+                };
+                match new_tab_idx.cmp(&self.active_idx) {
+                    Ordering::Greater => {
+                        for _ in 0..new_tab_idx - self.active_idx {
+                            self.next_tab_();
+                        }
+                    }
+                    Ordering::Less => {
+                        for _ in 0..self.active_idx - new_tab_idx {
+                            self.prev_tab_();
+                        }
+                    }
+                    Ordering::Equal => {}
+                }
+                self.tabs[self.active_idx].set_style(TabStyle::Normal);
+                TUIRet::KeyHandled
+            }
+            None => {
+                // multiple tabs can have same switch character so scan
+                // forwards instead of starting from the first tab
+                for i in 1..=self.tabs.len() {
+                    let idx = (self.active_idx + i) % self.tabs.len();
+                    if self.tabs[idx].switch == Some(c) {
+                        self.select_tab(idx);
+                        break;
+                    }
+                }
+                TUIRet::KeyHandled
+            }
+        }
+    }
+
     ////////////////////////////////////////////////////////////////////////////
     // Interfacing with tabs
 
@@ -1202,9 +1216,9 @@ impl TUI {
 
     pub(crate) fn set_tab_style(&mut self, style: TabStyle, target: &MsgTarget) {
         self.apply_to_target(target, false, &|tab: &mut Tab, is_active: bool| {
-            if !is_active
+            if (tab.widget.is_showing_status() || style != TabStyle::JoinOrPart)
                 && tab.style < style
-                && !(style == TabStyle::JoinOrPart && !tab.widget.is_showing_status())
+                && !is_active
             {
                 tab.set_style(style);
             }
diff --git a/crates/term_input/src/lib.rs b/crates/term_input/src/lib.rs
index ea279512..e3c82d69 100644
--- a/crates/term_input/src/lib.rs
+++ b/crates/term_input/src/lib.rs
@@ -28,7 +28,7 @@ extern crate log;
 // Public types
 ////////////////////////////////////////////////////////////////////////////////////////////////////
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq)]
 pub enum Key {
     AltArrow(Arrow),
     AltChar(char),
@@ -48,7 +48,7 @@ pub enum Key {
     Tab,
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq)]
 pub enum Arrow {
     Left,
     Right,
@@ -76,6 +76,7 @@ pub enum Event {
 // Byte sequences of key presses we want to capture
 ////////////////////////////////////////////////////////////////////////////////////////////////////
 
+// Note: `showkey -a` is an easy way to get these bytes
 byte_seq_parser! {
     parse_key_bytes -> Key, // Function name. Generation function type is
                             // fn(&[u8]) -> Option<(Key, usize)>.
@@ -110,21 +111,42 @@ byte_seq_parser! {
     [8] => Key::Backspace,
     [127] => Key::Backspace,
     [1] => Key::Ctrl('a'),
+    [2] => Key::Ctrl('b'),
+    [3] => Key::Ctrl('c'),
+    [4] => Key::Ctrl('d'),
     [5] => Key::Ctrl('e'),
-    [23] => Key::Ctrl('w'),
+    [6] => Key::Ctrl('f'),
+    [7] => Key::Ctrl('g'),
+    [10] => Key::Ctrl('j'),
     [11] => Key::Ctrl('k'),
-    [4] => Key::Ctrl('d'),
-    [3] => Key::Ctrl('c'),
-    [17] => Key::Ctrl('q'),
-    [16] => Key::Ctrl('p'),
+    [12] => Key::Ctrl('l'),
+    [13] => Key::Ctrl('m'),
     [14] => Key::Ctrl('n'),
+    [15] => Key::Ctrl('o'),
+    [16] => Key::Ctrl('p'),
+    [17] => Key::Ctrl('q'),
+    [18] => Key::Ctrl('r'),
+    [19] => Key::Ctrl('s'),
+    [20] => Key::Ctrl('t'),
     [21] => Key::Ctrl('u'),
+    [22] => Key::Ctrl('v'),
+    [23] => Key::Ctrl('w'),
     [24] => Key::Ctrl('x'),
+    [25] => Key::Ctrl('y'),
+    [26] => Key::Ctrl('z'),
 }
 
 // static XTERM_FOCUS_GAINED: [u8; 3] = [27, 91, 73];
 // static XTERM_FOCUS_LOST: [u8; 3] = [27, 91, 79];
 
+pub fn is_valid_key(key: Key) -> bool {
+    parse_key_bytes_is_valid_key(key)
+        | match key {
+            Key::AltChar(_) | Key::Char(_) => true,
+            _ => false,
+        }
+}
+
 ////////////////////////////////////////////////////////////////////////////////////////////////////
 
 pub struct Input {
diff --git a/crates/term_input_macros/src/lib.rs b/crates/term_input_macros/src/lib.rs
index 47f21c37..9d5081b0 100644
--- a/crates/term_input_macros/src/lib.rs
+++ b/crates/term_input_macros/src/lib.rs
@@ -1,10 +1,15 @@
 mod syntax;
 mod tree;
 
-use syntax::Input;
+use std::collections::HashSet;
 
 use proc_macro::TokenStream;
-use quote::quote;
+use proc_macro2::TokenStream as TokenStream2;
+use quote::{format_ident, quote};
+use syn::Ident;
+use syntax::Input;
+
+use crate::syntax::Rule;
 
 #[proc_macro]
 pub fn byte_seq_parser(input: TokenStream) -> TokenStream {
@@ -13,10 +18,31 @@ pub fn byte_seq_parser(input: TokenStream) -> TokenStream {
         fn_return_type,
         rules,
     } = syn::parse_macro_input!(input as syntax::Input);
-    let fn_body = tree::build_decision_tree(rules);
 
-    quote!(fn #fn_name(buf: &[u8]) -> Option<(#fn_return_type, usize)> {
+    let is_valid_key = build_is_valid_key(&fn_name, &rules);
+
+    let fn_body = tree::build_decision_tree(rules);
+    let byte_parser = quote!(fn #fn_name(buf: &[u8]) -> Option<(#fn_return_type, usize)> {
         #fn_body
-    })
-    .into()
+    });
+
+    quote!(#is_valid_key #byte_parser).into()
+}
+
+fn build_is_valid_key(fn_name: &Ident, rules: &[Rule]) -> TokenStream2 {
+    let keys = rules
+        .iter()
+        .map(|r| &r.value.0)
+        .collect::<HashSet<_>>()
+        .into_iter();
+    let fn_name = format_ident!("{}_is_valid_key", fn_name);
+
+    quote!(
+        fn #fn_name(k: Key) -> bool {
+            match k {
+                #(#keys => true,)*
+                _ => false,
+            }
+        }
+    )
 }
diff --git a/crates/tiny/config.yml b/crates/tiny/config.yml
index d2980b72..054cae28 100644
--- a/crates/tiny/config.yml
+++ b/crates/tiny/config.yml
@@ -139,3 +139,55 @@ colors:
         fg: red
         bg: default
         attrs: [bold]
+
+# Custom key mappings (optional)
+# [key_name]: [key_action]
+# Set a key to "disable" to disable it
+# key_map:
+    ### Defaults ###
+    # ctrl_c: exit
+    # ctrl_x: run_editor
+
+    # alt_1: 
+    #     tab_goto: 1
+    # alt_2: 
+    #     tab_goto: 2
+    # alt_3: 
+    #     tab_goto: 3
+    # alt_4: 
+    #     tab_goto: 4
+    # alt_5: 
+    #     tab_goto: 5
+    # alt_6: 
+    #     tab_goto: 6
+    # alt_7: 
+    #     tab_goto: 7
+    # alt_8: 
+    #     tab_goto: 8
+    # alt_9: 
+    #     tab_goto: 9
+    # alt_0: 
+    #     tab_goto: 0
+    # ctrl_n:     tab_next
+    # ctrl_p:     tab_prev
+
+    # shift_up:   messages_scroll_up
+    # shift_down: messages_scroll_down
+    # ctrl_u:     messages_page_up
+    # ctrl_d:     messages_page_down
+    # pgup:       messages_page_up
+    # pgdown:     messages_page_down
+    # home:       messages_scroll_top
+    # end:        messages_scroll_bottom
+
+    # left:       input_move_curs_left
+    # right:      input_move_curs_right
+    # ctrl_a:     input_move_curs_start
+    # ctrl_e:     input_move_curs_end
+    # ctrl_left:  input_move_word_left
+    # ctrl_right: input_move_word_right
+    # ctrl_k:     input_delete_to_end
+    # ctrl_w:     input_delete_prev_word
+    # backspace:  input_delete_prev_char
+    # del:        input_delete_next_char
+    # tab:        input_auto_complete


Test patch:
diff --git a/crates/term_input/src/tests.rs b/crates/term_input/src/tests.rs
index 5b805fc6..68a3a197 100644
--- a/crates/term_input/src/tests.rs
+++ b/crates/term_input/src/tests.rs
@@ -46,3 +46,9 @@ fn test_utf8_char() {
 fn test_tab_key() {
     assert_eq!(parse_single_event("\t".as_bytes()), Event::Key(Key::Tab));
 }
+
+#[test]
+fn test_is_valid() {
+    assert!(is_valid_key(Key::AltArrow(Arrow::Left)));
+    assert!(!is_valid_key(Key::Ctrl('h')));
+}