From c928d87025957ca1960b6bb4626d91d3f6dc117f Mon Sep 17 00:00:00 2001
From: micielski <73398428+micielski@users.noreply.github.com>
Date: Sun, 30 Jun 2024 14:59:38 +0000
Subject: [PATCH] configurable keybindings #23 (#36)

* start implementing config

* impl deserialize for keybinding

* refactor(config): restructure

* fix headers_hide

* use indexmap

* get rid of crate's event

* fix help

* refactor writing to lines in help

* default config

* fix uppercase letters

* fix space

* make help key in the bottom reflect reality

* rename SoftQuit to Close

* rename keymodifiers to crosstermkeymodifiers

* merge 2 match branches

* restructure keymap config
---
 Cargo.lock                                    |  13 +
 Cargo.toml                                    |   6 +-
 rm-config/Cargo.toml                          |   5 +-
 rm-config/defaults/keymap.toml                |  46 +++
 rm-config/src/keymap/actions/general.rs       |  73 ++++
 rm-config/src/keymap/actions/mod.rs           |   8 +
 rm-config/src/keymap/actions/torrents_tab.rs  |  40 +++
 rm-config/src/keymap/mod.rs                   | 319 ++++++++++++++++++
 rm-config/src/lib.rs                          | 181 ++--------
 rm-config/src/main_config.rs                  |  68 ++++
 rm-config/src/utils.rs                        |  50 +++
 rm-main/Cargo.toml                            |   1 +
 rm-main/src/action.rs                         | 118 -------
 rm-main/src/app.rs                            |  11 +-
 rm-main/src/main.rs                           |   1 -
 rm-main/src/transmission/action.rs            |  11 +-
 rm-main/src/transmission/fetchers.rs          |   2 +-
 rm-main/src/transmission/utils.rs             |   3 +-
 rm-main/src/tui.rs                            |  35 +-
 rm-main/src/ui/components/mod.rs              |   2 +-
 rm-main/src/ui/components/tabs.rs             |   3 +-
 rm-main/src/ui/global_popups/error.rs         |  10 +-
 rm-main/src/ui/global_popups/help.rs          |  54 +--
 rm-main/src/ui/global_popups/mod.rs           |   3 +-
 rm-main/src/ui/mod.rs                         |  14 +-
 rm-main/src/ui/tabs/search.rs                 |   2 +-
 rm-main/src/ui/tabs/torrents/input_manager.rs |   3 +-
 rm-main/src/ui/tabs/torrents/mod.rs           |   2 +-
 rm-main/src/ui/tabs/torrents/popups/files.rs  |   4 +-
 rm-main/src/ui/tabs/torrents/popups/mod.rs    |   3 +-
 rm-main/src/ui/tabs/torrents/popups/stats.rs  |   4 +-
 rm-main/src/ui/tabs/torrents/task_manager.rs  |   3 +-
 .../src/ui/tabs/torrents/tasks/add_magnet.rs  |   2 +-
 rm-main/src/ui/tabs/torrents/tasks/default.rs |   9 +-
 .../ui/tabs/torrents/tasks/delete_torrent.rs  |   2 +-
 rm-main/src/ui/tabs/torrents/tasks/filter.rs  |   2 +-
 rm-shared/Cargo.toml                          |  13 +
 rm-shared/src/action.rs                       |  92 +++++
 rm-shared/src/lib.rs                          |   1 +
 39 files changed, 856 insertions(+), 363 deletions(-)
 create mode 100644 rm-config/defaults/keymap.toml
 create mode 100644 rm-config/src/keymap/actions/general.rs
 create mode 100644 rm-config/src/keymap/actions/mod.rs
 create mode 100644 rm-config/src/keymap/actions/torrents_tab.rs
 create mode 100644 rm-config/src/keymap/mod.rs
 create mode 100644 rm-config/src/main_config.rs
 create mode 100644 rm-config/src/utils.rs
 delete mode 100644 rm-main/src/action.rs
 create mode 100644 rm-shared/Cargo.toml
 create mode 100644 rm-shared/src/action.rs
 create mode 100644 rm-shared/src/lib.rs

diff --git a/Cargo.lock b/Cargo.lock
index 4bda9d4..c1c9e79 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -276,6 +276,7 @@ dependencies = [
  "libc",
  "mio",
  "parking_lot",
+ "serde",
  "signal-hook",
  "signal-hook-mio",
  "winapi",
@@ -1245,13 +1246,23 @@ name = "rm-config"
 version = "0.3.3"
 dependencies = [
  "anyhow",
+ "crossterm",
  "ratatui",
+ "rm-shared",
  "serde",
+ "thiserror",
  "toml",
  "url",
  "xdg",
 ]
 
+[[package]]
+name = "rm-shared"
+version = "0.3.3"
+dependencies = [
+ "crossterm",
+]
+
 [[package]]
 name = "rustc-demangle"
 version = "0.1.24"
@@ -1349,6 +1360,7 @@ dependencies = [
  "magnetease",
  "ratatui",
  "rm-config",
+ "rm-shared",
  "serde",
  "throbber-widgets-tui",
  "tokio",
@@ -1921,6 +1933,7 @@ dependencies = [
  "form_urlencoded",
  "idna",
  "percent-encoding",
+ "serde",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 761fba5..3363769 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,7 @@ license = "GPL-3.0-or-later"
 
 [workspace.dependencies]
 rm-config = { version = "0.3", path = "rm-config" }
+rm-shared = { version = "0.3", path = "rm-shared" }
 
 magnetease = "0.1"
 anyhow = "1"
@@ -24,8 +25,9 @@ fuzzy-matcher = "0.3.7"
 clap = { version = "4.5.6", features = ["derive"] }
 base64 = "0.22"
 xdg = "2.5"
-url = "2.5"
+url = { version = "2.5", features = ["serde"] }
 toml = "0.8"
+thiserror = "1"
 
 # Async
 tokio = { version = "1", features = ["macros", "sync"] }
@@ -33,7 +35,7 @@ tokio-util = "0.7"
 futures = "0.3"
 
 # TUI
-crossterm = { version = "0.27", features = ["event-stream"] }
+crossterm = { version = "0.27", features = ["event-stream", "serde"] }
 ratatui = { version = "0.26", features = ["serde"] }
 tui-input = "0.8"
 tui-tree-widget = "0.20"
diff --git a/rm-config/Cargo.toml b/rm-config/Cargo.toml
index a519736..e1ab5f9 100644
--- a/rm-config/Cargo.toml
+++ b/rm-config/Cargo.toml
@@ -11,11 +11,12 @@ homepage.workspace = true
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
 [dependencies]
+rm-shared.workspace = true
 xdg.workspace = true
 toml.workspace = true
 serde.workspace = true
 anyhow.workspace = true
 url.workspace = true
 ratatui.workspace = true
-
-
+crossterm.workspace = true
+thiserror.workspace = true
diff --git a/rm-config/defaults/keymap.toml b/rm-config/defaults/keymap.toml
new file mode 100644
index 0000000..26272db
--- /dev/null
+++ b/rm-config/defaults/keymap.toml
@@ -0,0 +1,46 @@
+[general]
+keybindings = [
+  { on = "?", action = "ShowHelp" },
+  { on = "F1", action = "ShowHelp" },
+
+  { on = "q", action = "Quit" },
+  { on = "Esc", action = "Close" },
+  { on = "Enter", action = "Confirm" },
+  { on = " ", action = "Select" },
+  { on = "Tab", action = "SwitchFocus" },
+  { on = "/", action = "Search" },
+  
+  { on = "1", action = "SwitchToTorrents" },
+  { on = "2", action = "SwitchToSearch" },
+
+  { on = "Home", action = "GoToBeginning" },
+  { on = "End", action = "GoToEnd" },
+  { on = "PageUp", action = "ScrollPageUp" },
+  { on = "PageDown", action = "ScrollPageDown" },
+
+  { modifier = "Ctrl", on = "u", action = "ScrollPageUp" },
+  { modifier = "Ctrl", on = "d", action = "ScrollPageDown" },
+
+  # Arrows
+  { on = "Left", action = "Left" },
+  { on = "Right", action = "Right" },
+  { on = "Up", action = "Up"},
+  { on = "Down", action = "Down" },
+
+  # Vi
+  { on = "h", action = "Left" },
+  { on = "l", action = "Right" },
+  { on = "k", action = "Up" },
+  { on = "j", action = "Down" },
+]
+
+[torrents_tab]
+keybindings = [
+  { on = "a", action = "AddMagnet" },
+  { on = "p", action = "Pause" },
+  { on = "f", action = "ShowFiles" },
+  { on = "s", action = "ShowStats" },
+
+  { on = "d", action = "DeleteWithoutFiles" },
+  { on = "D", action = "DeleteWithFiles" },
+]
diff --git a/rm-config/src/keymap/actions/general.rs b/rm-config/src/keymap/actions/general.rs
new file mode 100644
index 0000000..de3bff7
--- /dev/null
+++ b/rm-config/src/keymap/actions/general.rs
@@ -0,0 +1,73 @@
+use rm_shared::action::Action;
+use serde::{Deserialize, Serialize};
+
+use super::UserAction;
+
+#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum GeneralAction {
+    ShowHelp,
+    Quit,
+    Close,
+    SwitchToTorrents,
+    SwitchToSearch,
+    Left,
+    Right,
+    Down,
+    Up,
+    Search,
+    SwitchFocus,
+    Confirm,
+    Select,
+    ScrollPageDown,
+    ScrollPageUp,
+    GoToBeginning,
+    GoToEnd,
+}
+
+impl UserAction for GeneralAction {
+    fn desc(&self) -> &'static str {
+        match self {
+            GeneralAction::ShowHelp => "toggle help",
+            GeneralAction::Quit => "quit Rustmission / a popup",
+            GeneralAction::Close => "close a popup / task",
+            GeneralAction::SwitchToTorrents => "switch to torrents tab",
+            GeneralAction::SwitchToSearch => "switch to search tab",
+            GeneralAction::Left => "switch to tab left",
+            GeneralAction::Right => "switch to tab right",
+            GeneralAction::Down => "move down",
+            GeneralAction::Up => "move up",
+            GeneralAction::Search => "search",
+            GeneralAction::SwitchFocus => "switch focus",
+            GeneralAction::Confirm => "confirm",
+            GeneralAction::Select => "select",
+            GeneralAction::ScrollPageDown => "scroll page down",
+            GeneralAction::ScrollPageUp => "scroll page up",
+            GeneralAction::GoToBeginning => "scroll to the beginning",
+            GeneralAction::GoToEnd => "scroll to the end",
+        }
+    }
+}
+
+impl From<GeneralAction> for Action {
+    fn from(value: GeneralAction) -> Self {
+        match value {
+            GeneralAction::ShowHelp => Action::ShowHelp,
+            GeneralAction::Quit => Action::Quit,
+            GeneralAction::Close => Action::Close,
+            GeneralAction::SwitchToTorrents => Action::ChangeTab(1),
+            GeneralAction::SwitchToSearch => Action::ChangeTab(2),
+            GeneralAction::Left => Action::Left,
+            GeneralAction::Right => Action::Right,
+            GeneralAction::Down => Action::Down,
+            GeneralAction::Up => Action::Up,
+            GeneralAction::Search => Action::Search,
+            GeneralAction::SwitchFocus => Action::ChangeFocus,
+            GeneralAction::Confirm => Action::Confirm,
+            GeneralAction::Select => Action::Select,
+            GeneralAction::ScrollPageDown => Action::ScrollDownPage,
+            GeneralAction::ScrollPageUp => Action::ScrollUpPage,
+            GeneralAction::GoToBeginning => Action::Home,
+            GeneralAction::GoToEnd => Action::End,
+        }
+    }
+}
diff --git a/rm-config/src/keymap/actions/mod.rs b/rm-config/src/keymap/actions/mod.rs
new file mode 100644
index 0000000..76131db
--- /dev/null
+++ b/rm-config/src/keymap/actions/mod.rs
@@ -0,0 +1,8 @@
+use rm_shared::action::Action;
+
+pub mod general;
+pub mod torrents_tab;
+
+pub trait UserAction: Into<Action> {
+    fn desc(&self) -> &'static str;
+}
diff --git a/rm-config/src/keymap/actions/torrents_tab.rs b/rm-config/src/keymap/actions/torrents_tab.rs
new file mode 100644
index 0000000..c1b39a7
--- /dev/null
+++ b/rm-config/src/keymap/actions/torrents_tab.rs
@@ -0,0 +1,40 @@
+use rm_shared::action::Action;
+use serde::{Deserialize, Serialize};
+
+use super::UserAction;
+
+#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum TorrentsAction {
+    AddMagnet,
+    Pause,
+    DeleteWithFiles,
+    DeleteWithoutFiles,
+    ShowFiles,
+    ShowStats,
+}
+
+impl UserAction for TorrentsAction {
+    fn desc(&self) -> &'static str {
+        match self {
+            TorrentsAction::AddMagnet => "add a magnet",
+            TorrentsAction::Pause => "pause/unpause",
+            TorrentsAction::DeleteWithFiles => "delete with files",
+            TorrentsAction::DeleteWithoutFiles => "delete without files",
+            TorrentsAction::ShowFiles => "show files",
+            TorrentsAction::ShowStats => "show statistics",
+        }
+    }
+}
+
+impl From<TorrentsAction> for Action {
+    fn from(value: TorrentsAction) -> Self {
+        match value {
+            TorrentsAction::AddMagnet => Action::AddMagnet,
+            TorrentsAction::Pause => Action::Pause,
+            TorrentsAction::DeleteWithFiles => Action::DeleteWithFiles,
+            TorrentsAction::DeleteWithoutFiles => Action::DeleteWithoutFiles,
+            TorrentsAction::ShowFiles => Action::ShowFiles,
+            TorrentsAction::ShowStats => Action::ShowStats,
+        }
+    }
+}
diff --git a/rm-config/src/keymap/mod.rs b/rm-config/src/keymap/mod.rs
new file mode 100644
index 0000000..7f8526b
--- /dev/null
+++ b/rm-config/src/keymap/mod.rs
@@ -0,0 +1,319 @@
+pub mod actions;
+
+use std::{
+    collections::HashMap, io::ErrorKind, marker::PhantomData, path::PathBuf, sync::OnceLock,
+};
+
+use anyhow::Result;
+use crossterm::event::{KeyCode, KeyModifiers as CrosstermKeyModifiers};
+use serde::{
+    de::{self, Visitor},
+    Deserialize, Serialize,
+};
+
+use crate::utils;
+use rm_shared::action::Action;
+
+use self::actions::{general::GeneralAction, torrents_tab::TorrentsAction};
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct KeymapConfig {
+    pub general: KeybindsHolder<GeneralAction>,
+    pub torrents_tab: KeybindsHolder<TorrentsAction>,
+    #[serde(skip)]
+    pub keymap: HashMap<(KeyCode, CrosstermKeyModifiers), Action>,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct KeybindsHolder<T: Into<Action>> {
+    pub keybindings: Vec<Keybinding<T>>,
+}
+
+#[derive(Serialize, Clone)]
+pub struct Keybinding<T: Into<Action>> {
+    pub on: KeyCode,
+    #[serde(default)]
+    pub modifier: KeyModifier,
+    pub action: T,
+}
+
+impl<T: Into<Action>> Keybinding<T> {
+    pub fn keycode_string(&self) -> String {
+        let key = match self.on {
+            KeyCode::Backspace => "Backspace".into(),
+            KeyCode::Enter => "Enter".into(),
+            KeyCode::Left => "".into(),
+            KeyCode::Right => "".into(),
+            KeyCode::Up => "".into(),
+            KeyCode::Down => "".into(),
+            KeyCode::Home => "Home".into(),
+            KeyCode::End => "End".into(),
+            KeyCode::PageUp => "PageUp".into(),
+            KeyCode::PageDown => "PageDown".into(),
+            KeyCode::Tab => "Tab".into(),
+            KeyCode::BackTab => todo!(),
+            KeyCode::Delete => todo!(),
+            KeyCode::Insert => "Insert".into(),
+            KeyCode::F(i) => format!("F{i}"),
+            KeyCode::Char(c) => {
+                if c == ' ' {
+                    "Space".into()
+                } else {
+                    c.into()
+                }
+            }
+            KeyCode::Null => todo!(),
+            KeyCode::Esc => "Esc".into(),
+            KeyCode::CapsLock => todo!(),
+            KeyCode::ScrollLock => todo!(),
+            KeyCode::NumLock => todo!(),
+            KeyCode::PrintScreen => todo!(),
+            KeyCode::Pause => todo!(),
+            KeyCode::Menu => todo!(),
+            KeyCode::KeypadBegin => todo!(),
+            KeyCode::Media(_) => todo!(),
+            KeyCode::Modifier(_) => todo!(),
+        };
+
+        if !self.modifier.is_none() {
+            format!("{}-{key}", self.modifier.to_str())
+        } else {
+            key
+        }
+    }
+}
+
+impl<T: Into<Action>> Keybinding<T> {
+    fn new(on: KeyCode, action: T, modifier: Option<KeyModifier>) -> Self {
+        Self {
+            on,
+            modifier: modifier.unwrap_or(KeyModifier::None),
+            action,
+        }
+    }
+}
+
+impl<'de, T: Into<Action> + Deserialize<'de>> Deserialize<'de> for Keybinding<T> {
+    fn deserialize<D>(deserializer: D) -> std::prelude::v1::Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        #[derive(Deserialize)]
+        #[serde(field_identifier, rename_all = "lowercase")]
+        enum Field {
+            On,
+            Modifier,
+            Action,
+        }
+
+        struct KeybindingVisitor<T> {
+            phantom: PhantomData<T>,
+        }
+
+        impl<'de, T: Into<Action> + Deserialize<'de>> Visitor<'de> for KeybindingVisitor<T> {
+            type Value = Keybinding<T>;
+
+            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+                formatter.write_str("struct Keybinding")
+            }
+
+            fn visit_map<A>(self, mut map: A) -> std::prelude::v1::Result<Self::Value, A::Error>
+            where
+                A: serde::de::MapAccess<'de>,
+            {
+                let mut on = None;
+                let mut modifier = None;
+                let mut action = None;
+                while let Some(key) = map.next_key()? {
+                    match key {
+                        Field::On => {
+                            if on.is_some() {
+                                return Err(de::Error::duplicate_field("on"));
+                            }
+                            let key = map.next_value::<String>()?;
+
+                            if key.len() == 1 {
+                                on = Some(KeyCode::Char(key.chars().next().unwrap()));
+                            } else if key.starts_with('F') && (key.len() == 2 || key.len() == 3) {
+                                let which_f = key[1..].parse::<u8>().map_err(|_| {
+                                    de::Error::invalid_value(
+                                        de::Unexpected::Str(&key),
+                                        &"something_correct",
+                                    )
+                                })?;
+                                on = Some(KeyCode::F(which_f));
+                            } else {
+                                on = {
+                                    match key.to_lowercase().as_str() {
+                                        "enter" => Some(KeyCode::Enter),
+                                        "esc" => Some(KeyCode::Esc),
+                                        "up" => Some(KeyCode::Up),
+                                        "down" => Some(KeyCode::Down),
+                                        "left" => Some(KeyCode::Left),
+                                        "right" => Some(KeyCode::Right),
+                                        "home" => Some(KeyCode::Home),
+                                        "end" => Some(KeyCode::End),
+                                        "pageup" => Some(KeyCode::PageUp),
+                                        "pagedown" => Some(KeyCode::PageDown),
+                                        "tab" => Some(KeyCode::Tab),
+                                        "backspace" => Some(KeyCode::Backspace),
+                                        "delete" => Some(KeyCode::Delete),
+
+                                        _ => {
+                                            return Err(de::Error::invalid_value(
+                                                de::Unexpected::Str(&key),
+                                                &"something correct",
+                                            ))
+                                        }
+                                    }
+                                };
+                            }
+                        }
+                        Field::Modifier => {
+                            if modifier.is_some() {
+                                return Err(de::Error::duplicate_field("modifier"));
+                            }
+                            modifier = Some(map.next_value());
+                        }
+                        Field::Action => {
+                            if action.is_some() {
+                                return Err(de::Error::duplicate_field("action"));
+                            }
+                            action = Some(map.next_value());
+                        }
+                    }
+                }
+                let on = on.ok_or_else(|| de::Error::missing_field("on"))?;
+                let action = action.ok_or_else(|| de::Error::missing_field("action"))??;
+                let modifier = modifier.transpose().unwrap();
+
+                if modifier.is_some() {
+                    if let KeyCode::Char(char) = on {
+                        if char.is_uppercase() {
+                            return Err(de::Error::custom(
+                                "you can't have a modifier with an uppercase letter, sorry",
+                            ));
+                        }
+                    }
+                }
+
+                Ok(Keybinding::new(on, action, modifier))
+            }
+        }
+
+        const FIELDS: &[&str] = &["on", "modifier", "action"];
+        deserializer.deserialize_struct(
+            "Keybinding",
+            FIELDS,
+            KeybindingVisitor {
+                phantom: PhantomData::default(),
+            },
+        )
+    }
+}
+
+#[derive(Serialize, Deserialize, Hash, Clone, Copy, PartialEq, Eq)]
+pub enum KeyModifier {
+    None,
+    Ctrl,
+    Shift,
+    Alt,
+    Super,
+    Meta,
+}
+
+impl KeyModifier {
+    fn to_str(self) -> &'static str {
+        match self {
+            KeyModifier::None => "",
+            KeyModifier::Ctrl => "CTRL",
+            KeyModifier::Shift => "SHIFT",
+            KeyModifier::Alt => "ALT",
+            KeyModifier::Super => "SUPER",
+            KeyModifier::Meta => "META",
+        }
+    }
+
+    fn is_none(self) -> bool {
+        self == KeyModifier::None
+    }
+}
+
+impl From<KeyModifier> for CrosstermKeyModifiers {
+    fn from(value: KeyModifier) -> Self {
+        match value {
+            KeyModifier::None => CrosstermKeyModifiers::NONE,
+            KeyModifier::Ctrl => CrosstermKeyModifiers::CONTROL,
+            KeyModifier::Shift => CrosstermKeyModifiers::SHIFT,
+            KeyModifier::Alt => CrosstermKeyModifiers::ALT,
+            KeyModifier::Super => CrosstermKeyModifiers::SUPER,
+            KeyModifier::Meta => CrosstermKeyModifiers::META,
+        }
+    }
+}
+
+impl Default for KeyModifier {
+    fn default() -> Self {
+        Self::None
+    }
+}
+
+impl KeymapConfig {
+    pub const FILENAME: &'static str = "keymap.toml";
+    const DEFAULT_CONFIG: &'static str = include_str!("../../defaults/keymap.toml");
+
+    pub fn init() -> Result<Self> {
+        match utils::fetch_config::<Self>(Self::FILENAME) {
+            Ok(mut keymap_config) => {
+                keymap_config.populate_hashmap();
+                return Ok(keymap_config);
+            }
+            Err(e) => match e {
+                utils::ConfigFetchingError::Io(e) if e.kind() == ErrorKind::NotFound => {
+                    let mut keymap_config =
+                        utils::put_config::<Self>(Self::DEFAULT_CONFIG, Self::FILENAME)?;
+                    keymap_config.populate_hashmap();
+                    return Ok(keymap_config);
+                }
+                _ => anyhow::bail!(e),
+            },
+        }
+    }
+
+    pub fn get_keys_for_action(&self, action: Action) -> Option<String> {
+        let mut keys = vec![];
+
+        for keybinding in &self.general.keybindings {
+            if action == keybinding.action.into() {
+                keys.push(keybinding.keycode_string());
+            }
+        }
+        for keybinding in &self.torrents_tab.keybindings {
+            if action == keybinding.action.into() {
+                keys.push(keybinding.keycode_string());
+            }
+        }
+
+        if keys.is_empty() {
+            return None;
+        } else {
+            Some(keys.join("/"))
+        }
+    }
+
+    fn populate_hashmap(&mut self) {
+        for keybinding in &self.general.keybindings {
+            let hash_value = (keybinding.on, keybinding.modifier.into());
+            self.keymap.insert(hash_value, keybinding.action.into());
+        }
+        for keybinding in &self.torrents_tab.keybindings {
+            let hash_value = (keybinding.on, keybinding.modifier.into());
+            self.keymap.insert(hash_value, keybinding.action.into());
+        }
+    }
+
+    pub fn path() -> &'static PathBuf {
+        static PATH: OnceLock<PathBuf> = OnceLock::new();
+        PATH.get_or_init(|| utils::get_config_path(Self::FILENAME))
+    }
+}
diff --git a/rm-config/src/lib.rs b/rm-config/src/lib.rs
index 6e8f85b..a2e3a8b 100644
--- a/rm-config/src/lib.rs
+++ b/rm-config/src/lib.rs
@@ -1,171 +1,40 @@
-use std::{
-    fs::File,
-    io::{Read, Write},
-    path::PathBuf,
-    sync::OnceLock,
-};
+pub mod keymap;
+mod main_config;
+mod utils;
 
-use anyhow::{bail, Context, Result};
-use ratatui::style::Color;
-use serde::{Deserialize, Serialize};
-use toml::Table;
-use xdg::BaseDirectories;
+use std::path::PathBuf;
 
-#[derive(Debug, Serialize, Deserialize)]
-pub struct Config {
-    pub connection: Connection,
-    pub general: General,
-}
+use anyhow::Result;
+use keymap::KeymapConfig;
+use main_config::MainConfig;
 
-#[derive(Debug, Serialize, Deserialize)]
-pub struct General {
-    #[serde(default)]
-    pub auto_hide: bool,
-    #[serde(default = "default_accent_color")]
-    pub accent_color: Color,
-    #[serde(default = "default_beginner_mode")]
-    pub beginner_mode: bool,
-    #[serde(default)]
-    pub headers_hide: bool,
-}
-
-fn default_accent_color() -> Color {
-    Color::LightMagenta
-}
-
-fn default_beginner_mode() -> bool {
-    true
+pub struct Config {
+    pub general: main_config::General,
+    pub connection: main_config::Connection,
+    pub keybindings: KeymapConfig,
+    pub directories: Directories,
 }
 
-#[derive(Debug, Serialize, Deserialize)]
-pub struct Connection {
-    pub username: Option<String>,
-    pub password: Option<String>,
-    pub url: String,
-    #[serde(default)]
-    pub torrents_refresh: u64,
-    #[serde(default)]
-    pub stats_refresh: u64,
-    #[serde(default)]
-    pub free_space_refresh: u64,
+pub struct Directories {
+    pub main_path: &'static PathBuf,
+    pub keymap_path: &'static PathBuf,
 }
 
-const DEFAULT_CONFIG: &str = include_str!("../defaults/config.toml");
-static XDG_DIRS: OnceLock<BaseDirectories> = OnceLock::new();
-static CONFIG_PATH: OnceLock<PathBuf> = OnceLock::new();
-
 impl Config {
     pub fn init() -> Result<Self> {
-        let Ok(table) = Self::table_from_home() else {
-            Self::put_default_conf_in_home()?;
-            // TODO: check if the user really changed the config.
-            println!(
-                "Update {:?} and start rustmission again",
-                Self::get_config_path()
-            );
-            std::process::exit(0);
-        };
+        let main_config = MainConfig::init()?;
+        let keybindings = KeymapConfig::init()?;
 
-        Self::table_config_verify(&table)?;
-
-        Self::table_to_config(&table)
-    }
-
-    fn table_from_home() -> Result<Table> {
-        let xdg_dirs = xdg::BaseDirectories::with_prefix("rustmission")?;
-        let config_path = xdg_dirs
-            .find_config_file("config.toml")
-            .ok_or_else(|| anyhow::anyhow!("config.toml not found"))?;
-
-        let mut config_buf = String::new();
-        let mut config_file = File::open(config_path).unwrap();
-        config_file.read_to_string(&mut config_buf).unwrap();
-        Ok(toml::from_str(&config_buf)?)
-    }
-
-    fn put_default_conf_in_home() -> Result<Table> {
-        let config_path = Self::get_config_path();
-        let mut config_file = File::create(config_path)?;
-        config_file.write_all(DEFAULT_CONFIG.as_bytes())?;
-        Ok(toml::from_str(DEFAULT_CONFIG)?)
-    }
-
-    fn table_to_config(table: &Table) -> Result<Self> {
-        let config_string = table.to_string();
-        let config: Self = toml::from_str(&config_string)?;
-        Ok(config)
-    }
-
-    fn table_config_verify(table: &Table) -> Result<()> {
-        let Some(connection_table) = table.get("connection").unwrap().as_table() else {
-            bail!("expected connection table")
+        let directories = Directories {
+            main_path: MainConfig::path(),
+            keymap_path: KeymapConfig::path(),
         };
 
-        let url = connection_table
-            .get("url")
-            .and_then(|url| url.as_str())
-            .with_context(|| {
-                format!(
-                    "no url given in: {}",
-                    Self::get_config_path().to_str().unwrap()
-                )
-            })?;
-
-        url::Url::parse(url).with_context(|| {
-            format!(
-                "invalid url '{url}' in {}",
-                Self::get_config_path().to_str().unwrap()
-            )
-        })?;
-
-        Ok(())
-    }
-
-    fn get_xdg_dirs() -> &'static BaseDirectories {
-        XDG_DIRS.get_or_init(|| xdg::BaseDirectories::with_prefix("rustmission").unwrap())
-    }
-
-    pub fn get_config_path() -> &'static PathBuf {
-        CONFIG_PATH.get_or_init(|| {
-            Self::get_xdg_dirs()
-                .place_config_file("config.toml")
-                .unwrap()
+        Ok(Self {
+            general: main_config.general,
+            connection: main_config.connection,
+            keybindings: keybindings.clone(),
+            directories,
         })
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    fn invalid_config() -> Table {
-        toml::toml! {
-            [connection]
-            username = "username"
-            password = "password"
-            auto_hide = "dfgoij"
-            url = "bad_url"
-        }
-    }
-
-    fn valid_config() -> Table {
-        toml::toml! {
-            [connection]
-            username = "username"
-            password = "password"
-            url = "http://192.168.1.1/transmission/rpc"
-        }
-    }
-
-    #[test]
-    fn validates_properly() {
-        let valid_config = valid_config();
-        assert!(Config::table_config_verify(&valid_config).is_ok());
-    }
-
-    #[test]
-    fn invalidates_properly() {
-        let invalid_config = invalid_config();
-        assert!(Config::table_config_verify(&invalid_config).is_err());
-    }
-}
diff --git a/rm-config/src/main_config.rs b/rm-config/src/main_config.rs
new file mode 100644
index 0000000..68e1f3c
--- /dev/null
+++ b/rm-config/src/main_config.rs
@@ -0,0 +1,68 @@
+use std::{path::PathBuf, sync::OnceLock};
+
+use anyhow::Result;
+use ratatui::style::Color;
+use serde::{Deserialize, Serialize};
+use url::Url;
+
+use crate::utils::{self, put_config};
+
+#[derive(Serialize, Deserialize)]
+pub struct MainConfig {
+    pub general: General,
+    pub connection: Connection,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct General {
+    #[serde(default)]
+    pub auto_hide: bool,
+    #[serde(default = "default_accent_color")]
+    pub accent_color: Color,
+    #[serde(default = "default_beginner_mode")]
+    pub beginner_mode: bool,
+    #[serde(default)]
+    pub headers_hide: bool,
+}
+
+fn default_accent_color() -> Color {
+    Color::LightMagenta
+}
+
+fn default_beginner_mode() -> bool {
+    true
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Connection {
+    pub username: Option<String>,
+    pub password: Option<String>,
+    pub url: Url,
+    #[serde(default)]
+    pub torrents_refresh: u64,
+    #[serde(default)]
+    pub stats_refresh: u64,
+    #[serde(default)]
+    pub free_space_refresh: u64,
+}
+
+impl MainConfig {
+    pub(crate) const FILENAME: &'static str = "config.toml";
+    const DEFAULT_CONFIG: &'static str = include_str!("../defaults/config.toml");
+
+    pub(crate) fn init() -> Result<Self> {
+        let Ok(config) = utils::fetch_config(Self::FILENAME) else {
+            put_config(Self::DEFAULT_CONFIG, Self::FILENAME)?;
+            // TODO: check if the user really changed the config.
+            println!("Update {:?} and start rustmission again", Self::path());
+            std::process::exit(0);
+        };
+
+        Ok(config)
+    }
+
+    pub(crate) fn path() -> &'static PathBuf {
+        static PATH: OnceLock<PathBuf> = OnceLock::new();
+        PATH.get_or_init(|| utils::get_config_path(Self::FILENAME))
+    }
+}
diff --git a/rm-config/src/utils.rs b/rm-config/src/utils.rs
new file mode 100644
index 0000000..102afde
--- /dev/null
+++ b/rm-config/src/utils.rs
@@ -0,0 +1,50 @@
+use std::{
+    fs::File,
+    io::{self, Read, Write},
+    path::PathBuf,
+    sync::OnceLock,
+};
+
+use anyhow::Result;
+use serde::de::DeserializeOwned;
+use thiserror::Error;
+use xdg::BaseDirectories;
+
+#[derive(Error, Debug)]
+pub enum ConfigFetchingError {
+    #[error(transparent)]
+    Io(#[from] io::Error),
+    #[error(transparent)]
+    Toml(#[from] toml::de::Error),
+}
+
+pub fn xdg_dirs() -> &'static BaseDirectories {
+    static XDG_DIRS: OnceLock<BaseDirectories> = OnceLock::new();
+    XDG_DIRS.get_or_init(|| xdg::BaseDirectories::with_prefix("rustmission").unwrap())
+}
+
+pub fn get_config_path(filename: &str) -> PathBuf {
+    xdg_dirs().place_config_file(filename).unwrap()
+}
+
+pub fn fetch_config<T: DeserializeOwned>(config_name: &str) -> Result<T, ConfigFetchingError> {
+    let config_path = xdg_dirs().find_config_file(config_name).ok_or_else(|| {
+        io::Error::new(io::ErrorKind::NotFound, format!("{config_name} not found"))
+    })?;
+
+    let mut config_buf = String::new();
+    let mut config_file = File::open(config_path)?;
+    config_file.read_to_string(&mut config_buf)?;
+
+    Ok(toml::from_str(&config_buf)?)
+}
+
+pub fn put_config<T: DeserializeOwned>(
+    content: &'static str,
+    filename: &str,
+) -> Result<T, io::Error> {
+    let config_path = get_config_path(filename);
+    let mut config_file = File::create(config_path)?;
+    config_file.write_all(content.as_bytes())?;
+    Ok(toml::from_str(content).expect("default configs are correct"))
+}
diff --git a/rm-main/Cargo.toml b/rm-main/Cargo.toml
index e6311ea..14b64ef 100644
--- a/rm-main/Cargo.toml
+++ b/rm-main/Cargo.toml
@@ -15,6 +15,7 @@ path = "src/main.rs"
 
 [dependencies]
 rm-config.workspace = true
+rm-shared.workspace = true
 magnetease.workspace = true
 anyhow.workspace = true
 serde.workspace = true
diff --git a/rm-main/src/action.rs b/rm-main/src/action.rs
deleted file mode 100644
index d3a8a76..0000000
--- a/rm-main/src/action.rs
+++ /dev/null
@@ -1,118 +0,0 @@
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
-use crate::{tui::Event, ui::global_popups::ErrorPopup};
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) enum Action {
-    HardQuit,
-    Quit,
-    SoftQuit,
-    Render,
-    Tick,
-    Up,
-    Down,
-    Left,
-    Right,
-    ScrollDownPage,
-    ScrollUpPage,
-    Home,
-    End,
-    Confirm,
-    Space,
-    ShowHelp,
-    ShowStats,
-    ShowFiles,
-    Search,
-    Pause,
-    DeleteWithoutFiles,
-    DeleteWithFiles,
-    SwitchToInputMode,
-    SwitchToNormalMode,
-    ChangeFocus,
-    AddMagnet,
-    ChangeTab(u8),
-    Input(KeyEvent),
-    Error(Box<ErrorPopup>),
-}
-
-impl Action {
-    pub fn is_render(&self) -> bool {
-        *self == Self::Render
-    }
-
-    pub fn is_quit(&self) -> bool {
-        *self == Self::HardQuit || *self == Self::Quit
-    }
-
-    pub fn is_soft_quit(&self) -> bool {
-        self.is_quit() || *self == Self::SoftQuit
-    }
-}
-
-#[derive(Clone, Copy, PartialEq, Eq)]
-pub enum Mode {
-    Input,
-    Normal,
-}
-
-pub fn event_to_action(mode: Mode, event: Event) -> Option<Action> {
-    use Action as A;
-
-    // Handle CTRL+C first
-    if let Event::Key(key_event) = event {
-        if key_event.modifiers == KeyModifiers::CONTROL
-            && (key_event.code == KeyCode::Char('c') || key_event.code == KeyCode::Char('C'))
-        {
-            return Some(A::HardQuit);
-        }
-    }
-
-    match event {
-        Event::Quit => Some(A::Quit),
-        Event::Error => todo!(),
-        Event::Render => Some(A::Render),
-        Event::Key(key) if mode == Mode::Input => Some(A::Input(key)),
-        Event::Key(key) => key_event_to_action(key),
-    }
-}
-
-fn key_event_to_action(key: KeyEvent) -> Option<Action> {
-    use Action as A;
-
-    match (key.modifiers, key.code) {
-        (KeyModifiers::CONTROL, KeyCode::Char('d')) => Some(A::ScrollDownPage),
-        (KeyModifiers::CONTROL, KeyCode::Char('u')) => Some(A::ScrollUpPage),
-        (_, keycode) => keycode_to_action(keycode),
-    }
-}
-
-fn keycode_to_action(key: KeyCode) -> Option<Action> {
-    use Action as A;
-    match key {
-        KeyCode::Char('q') | KeyCode::Char('Q') => Some(A::Quit),
-        KeyCode::Esc => Some(A::SoftQuit),
-        KeyCode::Tab => Some(A::ChangeFocus),
-        KeyCode::Home => Some(A::Home),
-        KeyCode::End => Some(A::End),
-        KeyCode::PageUp => Some(A::ScrollUpPage),
-        KeyCode::PageDown => Some(A::ScrollDownPage),
-        KeyCode::Char('j') | KeyCode::Down => Some(A::Down),
-        KeyCode::Char('k') | KeyCode::Up => Some(A::Up),
-        KeyCode::Char('h') | KeyCode::Left => Some(A::Left),
-        KeyCode::Char('l') | KeyCode::Right => Some(A::Right),
-        KeyCode::Char('?') | KeyCode::F(1) => Some(A::ShowHelp),
-        KeyCode::Char('s') => Some(A::ShowStats),
-        KeyCode::Char('f') => Some(A::ShowFiles),
-        KeyCode::Char('/') => Some(A::Search),
-        KeyCode::Char('a') => Some(A::AddMagnet),
-        KeyCode::Char('p') => Some(A::Pause),
-        KeyCode::Char('d') => Some(A::DeleteWithoutFiles),
-        KeyCode::Char('D') => Some(A::DeleteWithFiles),
-        KeyCode::Char(' ') => Some(A::Space),
-        KeyCode::Char(n @ '1'..='9') => {
-            Some(A::ChangeTab(n.to_digit(10).expect("This is ok") as u8))
-        }
-        KeyCode::Enter => Some(A::Confirm),
-        _ => None,
-    }
-}
diff --git a/rm-main/src/app.rs b/rm-main/src/app.rs
index 15f1c6f..c187bee 100644
--- a/rm-main/src/app.rs
+++ b/rm-main/src/app.rs
@@ -1,8 +1,10 @@
 use rm_config::Config;
+use rm_shared::action::event_to_action;
+use rm_shared::action::Action;
+use rm_shared::action::Mode;
 use std::sync::Arc;
 
 use crate::{
-    action::{event_to_action, Action, Mode},
     transmission::{self, TorrentAction},
     tui::Tui,
     ui::{components::Component, MainWindow},
@@ -44,9 +46,10 @@ impl Ctx {
                 });
             }
             Err(e) => {
-                let config_path = Config::get_config_path().to_str().unwrap();
+                let config_path = config.directories.main_path;
                 return Err(Error::msg(format!(
-                    "{e}\nIs the connection info in {config_path} correct?"
+                    "{e}\nIs the connection info in {:?} correct?",
+                    config_path
                 )));
             }
         }
@@ -114,7 +117,7 @@ impl App {
                 },
 
                 event = tui_event => {
-                    if let Some(action) = event_to_action(self.mode, event.unwrap()) {
+                    if let Some(action) = event_to_action(self.mode, event.unwrap(), &self.ctx.config.keybindings.keymap) {
                         if let Some(action) = self.update(action).await {
                             self.ctx.action_tx.send(action).unwrap();
                         }
diff --git a/rm-main/src/main.rs b/rm-main/src/main.rs
index 7168fa1..a55d156 100644
--- a/rm-main/src/main.rs
+++ b/rm-main/src/main.rs
@@ -1,4 +1,3 @@
-mod action;
 pub mod app;
 mod cli;
 pub mod transmission;
diff --git a/rm-main/src/transmission/action.rs b/rm-main/src/transmission/action.rs
index f0886e7..67bffa6 100644
--- a/rm-main/src/transmission/action.rs
+++ b/rm-main/src/transmission/action.rs
@@ -5,7 +5,9 @@ use transmission_rpc::types::{
     Id, SessionGet, Torrent, TorrentAction as RPCAction, TorrentAddArgs, TorrentSetArgs,
 };
 
-use crate::{action::Action, app, ui::global_popups::ErrorPopup};
+use crate::app;
+use rm_shared::action::Action;
+use rm_shared::action::ErrorMessage;
 
 #[derive(Debug)]
 pub enum TorrentAction {
@@ -44,8 +46,11 @@ pub async fn action_handler(ctx: app::Ctx, mut trans_rx: UnboundedReceiver<Torre
                         + url
                         + "\"\n"
                         + &e.to_string();
-                    let error_popup = Box::new(ErrorPopup::new(error_title, msg));
-                    ctx.send_action(Action::Error(error_popup));
+                    let error_message = ErrorMessage {
+                        title: error_title.to_string(),
+                        message: msg,
+                    };
+                    ctx.send_action(Action::Error(Box::new(error_message)));
                 }
             }
             TorrentAction::Stop(ids) => {
diff --git a/rm-main/src/transmission/fetchers.rs b/rm-main/src/transmission/fetchers.rs
index 4f65726..51c0029 100644
--- a/rm-main/src/transmission/fetchers.rs
+++ b/rm-main/src/transmission/fetchers.rs
@@ -6,10 +6,10 @@ use std::{
 use transmission_rpc::types::{FreeSpace, SessionStats, TorrentGetField};
 
 use crate::{
-    action::Action,
     app,
     ui::tabs::torrents::{rustmission_torrent::RustmissionTorrent, table_manager::TableManager},
 };
+use rm_shared::action::Action;
 
 pub async fn stats(ctx: app::Ctx, stats: Arc<Mutex<Option<SessionStats>>>) {
     loop {
diff --git a/rm-main/src/transmission/utils.rs b/rm-main/src/transmission/utils.rs
index 27fc0ef..f8d9b0f 100644
--- a/rm-main/src/transmission/utils.rs
+++ b/rm-main/src/transmission/utils.rs
@@ -14,9 +14,8 @@ pub fn client_from_config(config: &Config) -> TransClient {
         .as_ref()
         .unwrap_or(&"".to_string())
         .clone();
-    let url = config.connection.url.parse().unwrap();
 
     let auth = BasicAuth { user, password };
 
-    TransClient::with_auth(url, auth)
+    TransClient::with_auth(config.connection.url.clone(), auth)
 }
diff --git a/rm-main/src/tui.rs b/rm-main/src/tui.rs
index 31a80ec..7149f03 100644
--- a/rm-main/src/tui.rs
+++ b/rm-main/src/tui.rs
@@ -1,9 +1,9 @@
-use std::time::Duration;
+use std::{io, time::Duration};
 
 use anyhow::Result;
 use crossterm::{
     cursor,
-    event::{Event as CrosstermEvent, KeyEvent, KeyEventKind},
+    event::{Event, KeyEventKind},
     terminal::{EnterAlternateScreen, LeaveAlternateScreen},
 };
 use futures::{FutureExt, StreamExt};
@@ -14,17 +14,9 @@ use tokio::{
 };
 use tokio_util::sync::CancellationToken;
 
-#[derive(Clone, Debug)]
-pub enum Event {
-    Quit,
-    Error,
-    Render,
-    Key(KeyEvent),
-}
-
 pub struct Tui {
     pub terminal: ratatui::Terminal<Backend<std::io::Stdout>>,
-    pub task: JoinHandle<()>,
+    pub task: JoinHandle<Result<()>>,
     pub cancellation_token: CancellationToken,
     pub event_rx: UnboundedReceiver<Event>,
     pub event_tx: UnboundedSender<Event>,
@@ -35,7 +27,7 @@ impl Tui {
         let terminal = ratatui::Terminal::new(Backend::new(std::io::stdout()))?;
         let (event_tx, event_rx) = mpsc::unbounded_channel();
         let cancellation_token = CancellationToken::new();
-        let task = tokio::spawn(async {});
+        let task = tokio::spawn(async { Ok(()) });
         Ok(Self {
             terminal,
             task,
@@ -45,7 +37,7 @@ impl Tui {
         })
     }
 
-    pub fn start(&mut self) {
+    pub fn start(&mut self) -> Result<()> {
         self.cancel();
         self.cancellation_token = CancellationToken::new();
         let cancellation_token = self.cancellation_token.clone();
@@ -57,26 +49,29 @@ impl Tui {
                 let crossterm_event = reader.next().fuse();
                 tokio::select! {
                   _ = cancellation_token.cancelled() => break,
-                  event = crossterm_event => Self::handle_crossterm_event(event, &event_tx),
+                  event = crossterm_event => Self::handle_crossterm_event::<io::Error>(event, &event_tx)?,
                 }
             }
+            Ok(())
         });
+        Ok(())
     }
 
     fn handle_crossterm_event<T>(
-        event: Option<Result<CrosstermEvent, T>>,
+        event: Option<Result<Event, io::Error>>,
         event_tx: &UnboundedSender<Event>,
-    ) {
+    ) -> Result<()> {
         match event {
-            Some(Ok(CrosstermEvent::Key(key))) => {
+            Some(Ok(Event::Key(key))) => {
                 if key.kind == KeyEventKind::Press {
                     event_tx.send(Event::Key(key)).unwrap();
                 }
             }
-            Some(Ok(CrosstermEvent::Resize(_, _))) => event_tx.send(Event::Render).unwrap(),
-            Some(Err(_)) => event_tx.send(Event::Error).unwrap(),
+            Some(Ok(Event::Resize(x, y))) => event_tx.send(Event::Resize(x, y)).unwrap(),
+            Some(Err(e)) => Err(e)?,
             _ => (),
         }
+        Ok(())
     }
 
     pub(crate) fn stop(&self) {
@@ -97,7 +92,7 @@ impl Tui {
     pub(crate) fn enter(&mut self) -> Result<()> {
         crossterm::terminal::enable_raw_mode()?;
         crossterm::execute!(std::io::stdout(), EnterAlternateScreen, cursor::Hide)?;
-        self.start();
+        self.start()?;
         Ok(())
     }
 
diff --git a/rm-main/src/ui/components/mod.rs b/rm-main/src/ui/components/mod.rs
index fb4eb91..8d85944 100644
--- a/rm-main/src/ui/components/mod.rs
+++ b/rm-main/src/ui/components/mod.rs
@@ -4,7 +4,7 @@ pub mod tabs;
 use ratatui::prelude::*;
 use ratatui::Frame;
 
-use crate::action::Action;
+use rm_shared::action::Action;
 pub use tabs::TabComponent;
 
 pub trait Component {
diff --git a/rm-main/src/ui/components/tabs.rs b/rm-main/src/ui/components/tabs.rs
index 0a56e85..293d4a4 100644
--- a/rm-main/src/ui/components/tabs.rs
+++ b/rm-main/src/ui/components/tabs.rs
@@ -1,4 +1,5 @@
-use crate::{action::Action, app};
+use crate::app;
+use rm_shared::action::Action;
 
 use super::Component;
 use ratatui::{layout::Flex, prelude::*, widgets::Tabs};
diff --git a/rm-main/src/ui/global_popups/error.rs b/rm-main/src/ui/global_popups/error.rs
index bedeca3..faf0ec1 100644
--- a/rm-main/src/ui/global_popups/error.rs
+++ b/rm-main/src/ui/global_popups/error.rs
@@ -3,10 +3,8 @@ use ratatui::{
     widgets::{Block, Clear, Paragraph, Wrap},
 };
 
-use crate::{
-    action::Action,
-    ui::{centered_rect, components::Component},
-};
+use crate::ui::{centered_rect, components::Component};
+use rm_shared::action::Action;
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ErrorPopup {
@@ -16,7 +14,7 @@ pub struct ErrorPopup {
 }
 
 impl ErrorPopup {
-    pub fn new(title: &'static str, message: String) -> Self {
+    pub fn new(title: &str, message: String) -> Self {
         Self {
             title: title.to_owned(),
             message,
@@ -28,7 +26,7 @@ impl Component for ErrorPopup {
     fn handle_actions(&mut self, action: Action) -> Option<Action> {
         match action {
             _ if action.is_soft_quit() => Some(action),
-            Action::Confirm => Some(Action::SoftQuit),
+            Action::Confirm => Some(Action::Close),
             _ => None,
         }
     }
diff --git a/rm-main/src/ui/global_popups/help.rs b/rm-main/src/ui/global_popups/help.rs
index 2b06f52..71d003b 100644
--- a/rm-main/src/ui/global_popups/help.rs
+++ b/rm-main/src/ui/global_popups/help.rs
@@ -1,3 +1,5 @@
+use std::collections::BTreeMap;
+
 use ratatui::{
     prelude::*,
     widgets::{
@@ -7,10 +9,11 @@ use ratatui::{
 };
 
 use crate::{
-    action::Action,
     app,
     ui::{centered_rect, components::Component},
 };
+use rm_config::keymap::{actions::UserAction, Keybinding};
+use rm_shared::action::Action;
 
 macro_rules! add_line {
     ($lines:expr, $key:expr, $description:expr) => {
@@ -30,13 +33,31 @@ impl HelpPopup {
     pub const fn new(ctx: app::Ctx) -> Self {
         Self { ctx }
     }
+
+    fn write_keybindings<T: Into<Action> + UserAction + Ord>(
+        keybindings: &[Keybinding<T>],
+        lines: &mut Vec<Line>,
+    ) {
+        let mut keys = BTreeMap::new();
+
+        for keybinding in keybindings {
+            keys.entry(&keybinding.action)
+                .or_insert_with(Vec::new)
+                .push(keybinding.keycode_string());
+        }
+
+        for (action, keycodes) in keys {
+            let keycode_string = keycodes.join(" / ");
+            add_line!(lines, keycode_string, action.desc());
+        }
+    }
 }
 
 impl Component for HelpPopup {
     fn handle_actions(&mut self, action: Action) -> Option<Action> {
         match action {
-            action if action.is_soft_quit() => Some(Action::SoftQuit),
-            Action::Confirm | Action::ShowHelp => Some(Action::SoftQuit),
+            action if action.is_soft_quit() => Some(Action::Close),
+            Action::Confirm | Action::ShowHelp => Some(Action::Close),
             _ => None,
         }
     }
@@ -67,22 +88,7 @@ impl Component for HelpPopup {
         )])
         .centered()];
 
-        add_line!(lines, "? / F1", "show/hide help");
-        add_line!(lines, "q", "quit Rustmission / a popup");
-        add_line!(lines, "ESC", "close a popup / task");
-        add_line!(lines, "1", "switch to torrents tab");
-        add_line!(lines, "2", "switch to search tab");
-        add_line!(lines, "h / ←", "switch to tab left of current tab");
-        add_line!(lines, "l / →", "switch to tab right of current tab");
-        add_line!(lines, "j / ↓", "move down");
-        add_line!(lines, "k / ↑", "move up");
-        add_line!(lines, "/", "search or filter");
-        add_line!(lines, "TAB", "switch focus");
-        add_line!(lines, "Enter", "confirm");
-        add_line!(lines, "CTRL-d", "scroll page down");
-        add_line!(lines, "CTRL-u", "scroll page up");
-        add_line!(lines, "Home", "scroll to the beginning");
-        add_line!(lines, "End", "scroll to the end");
+        Self::write_keybindings(&self.ctx.config.keybindings.general.keybindings, &mut lines);
 
         lines.push(
             Line::from(vec![Span::styled(
@@ -92,12 +98,10 @@ impl Component for HelpPopup {
             .centered(),
         );
 
-        add_line!(lines, "a", "add a magnet url");
-        add_line!(lines, "p", "pause/unpause a torrent");
-        add_line!(lines, "d", "delete a torrent without files");
-        add_line!(lines, "D", "delete a torrent with files");
-        add_line!(lines, "f", "show files of a torrent");
-        add_line!(lines, "s", "show statistics");
+        Self::write_keybindings(
+            &self.ctx.config.keybindings.torrents_tab.keybindings,
+            &mut lines,
+        );
 
         let help_text = Text::from(lines);
         let help_paragraph = Paragraph::new(help_text);
diff --git a/rm-main/src/ui/global_popups/mod.rs b/rm-main/src/ui/global_popups/mod.rs
index 91cf956..2ea2007 100644
--- a/rm-main/src/ui/global_popups/mod.rs
+++ b/rm-main/src/ui/global_popups/mod.rs
@@ -6,7 +6,8 @@ use ratatui::prelude::*;
 pub use error::ErrorPopup;
 pub use help::HelpPopup;
 
-use crate::{action::Action, app};
+use crate::app;
+use rm_shared::action::Action;
 
 use super::components::Component;
 
diff --git a/rm-main/src/ui/mod.rs b/rm-main/src/ui/mod.rs
index 3db2bde..43954ab 100644
--- a/rm-main/src/ui/mod.rs
+++ b/rm-main/src/ui/mod.rs
@@ -2,16 +2,15 @@ pub mod components;
 pub mod global_popups;
 pub mod tabs;
 
-use crate::ui::tabs::torrents::TorrentsTab;
+use crate::ui::{global_popups::ErrorPopup, tabs::torrents::TorrentsTab};
 
 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
 use ratatui::prelude::*;
 use tui_input::InputRequest;
 
-use crate::{
-    action::Action,
-    app::{self},
-};
+use rm_shared::action::Action;
+
+use crate::app::{self};
 
 use self::{
     components::{tabs::CurrentTab, Component, TabComponent},
@@ -45,8 +44,9 @@ impl Component for MainWindow {
         use Action as A;
 
         match action {
-            A::Error(e_popup) => {
-                self.global_popup_manager.error_popup = Some(*e_popup);
+            A::Error(error) => {
+                let error_popup = ErrorPopup::new(&error.title, error.message);
+                self.global_popup_manager.error_popup = Some(error_popup);
                 Some(A::Render)
             }
             A::ShowHelp => self.global_popup_manager.handle_actions(action),
diff --git a/rm-main/src/ui/tabs/search.rs b/rm-main/src/ui/tabs/search.rs
index 96ff320..be53710 100644
--- a/rm-main/src/ui/tabs/search.rs
+++ b/rm-main/src/ui/tabs/search.rs
@@ -15,7 +15,6 @@ use tokio::sync::mpsc::{self, UnboundedSender};
 use tui_input::Input;
 
 use crate::{
-    action::Action,
     app,
     transmission::TorrentAction,
     ui::{
@@ -24,6 +23,7 @@ use crate::{
     },
     utils::bytes_to_human_format,
 };
+use rm_shared::action::Action;
 
 #[derive(Clone, Copy, PartialEq, Eq)]
 enum SearchFocus {
diff --git a/rm-main/src/ui/tabs/torrents/input_manager.rs b/rm-main/src/ui/tabs/torrents/input_manager.rs
index 93ff5ae..ba8cdd8 100644
--- a/rm-main/src/ui/tabs/torrents/input_manager.rs
+++ b/rm-main/src/ui/tabs/torrents/input_manager.rs
@@ -4,7 +4,8 @@ use ratatui::{
 };
 use tui_input::{Input, InputRequest};
 
-use crate::{action::Action, app, ui::components::Component};
+use crate::{app, ui::components::Component};
+use rm_shared::action::Action;
 
 pub struct InputManager {
     input: Input,
diff --git a/rm-main/src/ui/tabs/torrents/mod.rs b/rm-main/src/ui/tabs/torrents/mod.rs
index b498ffe..167cb75 100644
--- a/rm-main/src/ui/tabs/torrents/mod.rs
+++ b/rm-main/src/ui/tabs/torrents/mod.rs
@@ -15,10 +15,10 @@ use ratatui::prelude::*;
 use ratatui::widgets::{Row, Table};
 use transmission_rpc::types::TorrentStatus;
 
-use crate::action::Action;
 use crate::ui::components::table::GenericTable;
 use crate::ui::components::Component;
 use crate::{app, transmission};
+use rm_shared::action::Action;
 
 use self::bottom_stats::BottomStats;
 use self::popups::files::FilesPopup;
diff --git a/rm-main/src/ui/tabs/torrents/popups/files.rs b/rm-main/src/ui/tabs/torrents/popups/files.rs
index 0bfce3d..7806fce 100644
--- a/rm-main/src/ui/tabs/torrents/popups/files.rs
+++ b/rm-main/src/ui/tabs/torrents/popups/files.rs
@@ -15,11 +15,11 @@ use transmission_rpc::types::{Id, Torrent, TorrentSetArgs};
 use tui_tree_widget::{Tree, TreeItem, TreeState};
 
 use crate::{
-    action::Action,
     app,
     transmission::TorrentAction,
     ui::{centered_rect, components::Component},
 };
+use rm_shared::action::Action;
 
 pub struct FilesPopup {
     ctx: app::Ctx,
@@ -111,7 +111,7 @@ impl Component for FilesPopup {
                 Some(A::Render)
             }
             (A::Confirm, CurrentFocus::CloseButton) => Some(A::Quit),
-            (A::Space | A::Confirm, CurrentFocus::Files) => {
+            (A::Select | A::Confirm, CurrentFocus::Files) => {
                 if let Some(torrent) = &mut *self.torrent.lock().unwrap() {
                     let wanted_ids = torrent.wanted.as_mut().unwrap();
 
diff --git a/rm-main/src/ui/tabs/torrents/popups/mod.rs b/rm-main/src/ui/tabs/torrents/popups/mod.rs
index 6b784d6..398f57e 100644
--- a/rm-main/src/ui/tabs/torrents/popups/mod.rs
+++ b/rm-main/src/ui/tabs/torrents/popups/mod.rs
@@ -1,5 +1,6 @@
 use self::{files::FilesPopup, stats::StatisticsPopup};
-use crate::{action::Action, ui::components::Component};
+use crate::ui::components::Component;
+use rm_shared::action::Action;
 
 use ratatui::prelude::*;
 
diff --git a/rm-main/src/ui/tabs/torrents/popups/stats.rs b/rm-main/src/ui/tabs/torrents/popups/stats.rs
index db846f7..3c02123 100644
--- a/rm-main/src/ui/tabs/torrents/popups/stats.rs
+++ b/rm-main/src/ui/tabs/torrents/popups/stats.rs
@@ -8,11 +8,11 @@ use ratatui::{
 use transmission_rpc::types::SessionStats;
 
 use crate::{
-    action::Action,
     app,
     ui::{centered_rect, components::Component},
     utils::bytes_to_human_format,
 };
+use rm_shared::action::Action;
 
 pub struct StatisticsPopup {
     stats: SessionStats,
@@ -30,7 +30,7 @@ impl Component for StatisticsPopup {
         use Action as A;
         match action {
             _ if action.is_soft_quit() => Some(action),
-            A::Confirm => Some(Action::SoftQuit),
+            A::Confirm => Some(Action::Close),
             _ => None,
         }
     }
diff --git a/rm-main/src/ui/tabs/torrents/task_manager.rs b/rm-main/src/ui/tabs/torrents/task_manager.rs
index cbe4888..55b8d91 100644
--- a/rm-main/src/ui/tabs/torrents/task_manager.rs
+++ b/rm-main/src/ui/tabs/torrents/task_manager.rs
@@ -2,7 +2,8 @@ use std::sync::{Arc, Mutex};
 
 use ratatui::prelude::*;
 
-use crate::{action::Action, app, ui::components::Component};
+use crate::{app, ui::components::Component};
+use rm_shared::action::Action;
 
 use super::{
     tasks::{
diff --git a/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs b/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs
index 4840752..bbffbe8 100644
--- a/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs
+++ b/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs
@@ -2,11 +2,11 @@ use crossterm::event::{KeyCode, KeyEvent};
 use ratatui::prelude::*;
 
 use crate::{
-    action::Action,
     app,
     transmission::TorrentAction,
     ui::{components::Component, tabs::torrents::input_manager::InputManager, to_input_request},
 };
+use rm_shared::action::Action;
 
 pub struct AddMagnetBar {
     input_magnet_mgr: InputManager,
diff --git a/rm-main/src/ui/tabs/torrents/tasks/default.rs b/rm-main/src/ui/tabs/torrents/tasks/default.rs
index 201e26b..f508350 100644
--- a/rm-main/src/ui/tabs/torrents/tasks/default.rs
+++ b/rm-main/src/ui/tabs/torrents/tasks/default.rs
@@ -15,7 +15,14 @@ impl DefaultBar {
 impl Component for DefaultBar {
     fn render(&mut self, f: &mut ratatui::Frame<'_>, rect: Rect) {
         if self.ctx.config.general.beginner_mode {
-            f.render_widget("F1 - help", rect)
+            if let Some(keys) = self
+                .ctx
+                .config
+                .keybindings
+                .get_keys_for_action(rm_shared::action::Action::ShowHelp)
+            {
+                f.render_widget(format!("󰘥 {keys} - help"), rect)
+            }
         }
     }
 }
diff --git a/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs b/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs
index 5a1dc59..0d5cb28 100644
--- a/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs
+++ b/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs
@@ -3,11 +3,11 @@ use ratatui::prelude::*;
 use transmission_rpc::types::Id;
 
 use crate::{
-    action::Action,
     app,
     transmission::TorrentAction,
     ui::{components::Component, tabs::torrents::input_manager::InputManager, to_input_request},
 };
+use rm_shared::action::Action;
 
 pub struct DeleteBar {
     torrents_to_delete: Vec<Id>,
diff --git a/rm-main/src/ui/tabs/torrents/tasks/filter.rs b/rm-main/src/ui/tabs/torrents/tasks/filter.rs
index f58cecc..8854a65 100644
--- a/rm-main/src/ui/tabs/torrents/tasks/filter.rs
+++ b/rm-main/src/ui/tabs/torrents/tasks/filter.rs
@@ -4,7 +4,6 @@ use crossterm::event::KeyCode;
 use ratatui::prelude::*;
 
 use crate::{
-    action::Action,
     app,
     ui::{
         components::Component,
@@ -12,6 +11,7 @@ use crate::{
         to_input_request,
     },
 };
+use rm_shared::action::Action;
 
 pub struct FilterBar {
     input: InputManager,
diff --git a/rm-shared/Cargo.toml b/rm-shared/Cargo.toml
new file mode 100644
index 0000000..66fa6de
--- /dev/null
+++ b/rm-shared/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "rm-shared"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+repository.workspace = true
+homepage.workspace = true
+license.workspace = true
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+crossterm.workspace = true
diff --git a/rm-shared/src/action.rs b/rm-shared/src/action.rs
new file mode 100644
index 0000000..f06a9ae
--- /dev/null
+++ b/rm-shared/src/action.rs
@@ -0,0 +1,92 @@
+use std::collections::HashMap;
+
+use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Action {
+    HardQuit,
+    Quit,
+    Close,
+    Tick,
+    Render,
+    Up,
+    Down,
+    Left,
+    Right,
+    ScrollDownPage,
+    ScrollUpPage,
+    Home,
+    End,
+    Confirm,
+    Select,
+    ShowHelp,
+    ShowStats,
+    ShowFiles,
+    Search,
+    Pause,
+    DeleteWithoutFiles,
+    DeleteWithFiles,
+    SwitchToInputMode,
+    SwitchToNormalMode,
+    ChangeFocus,
+    AddMagnet,
+    ChangeTab(u8),
+    Input(KeyEvent),
+    Error(Box<ErrorMessage>),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ErrorMessage {
+    pub title: String,
+    pub message: String,
+}
+
+impl Action {
+    pub fn is_render(&self) -> bool {
+        *self == Self::Render
+    }
+
+    pub fn is_quit(&self) -> bool {
+        *self == Self::HardQuit || *self == Self::Quit
+    }
+
+    pub fn is_soft_quit(&self) -> bool {
+        self.is_quit() || *self == Self::Close
+    }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Mode {
+    Input,
+    Normal,
+}
+
+pub fn event_to_action(
+    mode: Mode,
+    event: Event,
+    keymap: &HashMap<(KeyCode, KeyModifiers), Action>,
+) -> Option<Action> {
+    use Action as A;
+
+    // Handle CTRL+C first
+    if let Event::Key(key_event) = event {
+        if key_event.modifiers == KeyModifiers::CONTROL
+            && (key_event.code == KeyCode::Char('c') || key_event.code == KeyCode::Char('C'))
+        {
+            return Some(A::HardQuit);
+        }
+    }
+
+    match event {
+        Event::Key(key) if mode == Mode::Input => Some(A::Input(key)),
+        Event::Key(key) => {
+            if let KeyCode::Char(e) = key.code {
+                if e.is_uppercase() {
+                    return keymap.get(&(key.code, KeyModifiers::NONE)).cloned();
+                }
+            }
+            keymap.get(&(key.code, key.modifiers)).cloned()
+        }
+        _ => None,
+    }
+}
diff --git a/rm-shared/src/lib.rs b/rm-shared/src/lib.rs
new file mode 100644
index 0000000..e9a6726
--- /dev/null
+++ b/rm-shared/src/lib.rs
@@ -0,0 +1 @@
+pub mod action;