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;