From 06fa7c003d9155cab83acc29a198292fc0f8b904 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Mon, 7 Jul 2025 19:18:18 +0300 Subject: [PATCH] feat(config): add config file --- Cargo.lock | 120 +++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 7 ++- config/default.toml | 22 ++++++++ src/app/command.rs | 29 +++++----- src/app/mod.rs | 13 +++-- src/app/torrent.rs | 4 +- src/config/colors.rs | 64 ++++++++++++++++++++++ src/config/keybinds.rs | 87 ++++++++++++++++++++++++++++++ src/config/mod.rs | 61 +++++++++++++++++++++ src/event.rs | 2 +- src/handler.rs | 109 +++++++++++++++++++++++++++++-------- src/lib.rs | 15 ++---- src/log.rs | 2 +- src/main.rs | 16 ++++-- src/tui.rs | 2 +- src/ui/help.rs | 35 ++++++------ src/ui/mod.rs | 6 +-- src/ui/table.rs | 16 ++++-- tests/app.rs | 23 +++++--- tests/handler.rs | 75 +++++++++----------------- 20 files changed, 562 insertions(+), 146 deletions(-) create mode 100644 config/default.toml create mode 100644 src/config/colors.rs create mode 100644 src/config/keybinds.rs create mode 100644 src/config/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 7895f13..79eb32d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,6 +322,27 @@ dependencies = [ "syn", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -741,6 +762,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indoc" version = "2.0.6" @@ -813,6 +844,16 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libredox" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -944,6 +985,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "overload" version = "0.1.1" @@ -1165,6 +1212,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.12", +] + [[package]] name = "regex" version = "1.11.1" @@ -1395,6 +1453,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1693,6 +1760,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.2" @@ -1843,9 +1951,12 @@ version = "0.1.0" dependencies = [ "color-eyre", "crossterm 0.29.0", + "dirs", "ratatui", + "serde", "thiserror 2.0.12", "tokio", + "toml", "tracing", "tracing-appender", "tracing-subscriber", @@ -2210,6 +2321,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index 855d4e0..b0acb41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,13 @@ edition = "2021" color-eyre = "0.6" crossterm = "0.29" ratatui = { version = "0.29" } -thiserror = "2.0" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tracing = "0.1" -tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +thiserror = "2.0" +tracing-appender = "0.2" +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +dirs = "6.0" transmission-rpc = "0.5" url = "2.5" diff --git a/config/default.toml b/config/default.toml new file mode 100644 index 0000000..02931f0 --- /dev/null +++ b/config/default.toml @@ -0,0 +1,22 @@ +[keybinds] +quit = "q" +next_tab = "l" +prev_tab = "h" +next_torrent = "j" +prev_torrent = "k" +switch_tab_1 = "1" +switch_tab_2 = "2" +switch_tab_3 = "3" +toggle_torrent = "enter" +toggle_all = "a" +delete = "d" +delete_force = "D" +select = " " +toggle_help = "?" + +[colors] +highlight_background = "magenta" +highlight_foreground = "black" +warning_foreground = "yellow" +info_foreground = "blue" +error_foreground = "red" diff --git a/src/app/command.rs b/src/app/command.rs index a7573dc..b234b4e 100644 --- a/src/app/command.rs +++ b/src/app/command.rs @@ -3,7 +3,7 @@ use std::{collections::HashSet, path::Path}; use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus}; impl Torrents { - pub async fn toggle(&mut self, ids: Selected) -> anyhow::Result<()> { + pub async fn toggle(&mut self, ids: Selected) -> color_eyre::eyre::Result<()> { let ids: HashSet<_> = ids.into(); let torrents_to_toggle: Vec<_> = self .torrents @@ -20,13 +20,14 @@ impl Torrents { self.client .torrent_action(action, vec![id]) .await - .map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?; + .map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?; + } } Ok(()) } - pub async fn toggle_all(&mut self) -> anyhow::Result<()> { + pub async fn toggle_all(&mut self) -> color_eyre::eyre::Result<()> { let torrents_to_toggle: Vec<_> = self .torrents .iter() @@ -47,16 +48,16 @@ impl Torrents { self.client .torrent_action(action, vec![id]) .await - .map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?; + .map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?; } Ok(()) } - pub async fn start_all(&mut self) -> anyhow::Result<()> { + pub async fn start_all(&mut self) -> color_eyre::eyre::Result<()> { self.action_all(TorrentAction::StartNow).await } - pub async fn stop_all(&mut self) -> anyhow::Result<()> { + pub async fn stop_all(&mut self) -> color_eyre::eyre::Result<()> { self.action_all(TorrentAction::Stop).await } @@ -65,35 +66,35 @@ impl Torrents { torrent: &Torrent, location: &Path, move_from: Option, - ) -> anyhow::Result<()> { + ) -> color_eyre::eyre::Result<()> { if let Some(id) = torrent.id() { self.client .torrent_set_location(vec![id], location.to_string_lossy().into(), move_from) .await - .map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?; + .map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?; } Ok(()) } - pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> anyhow::Result<()> { + pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> color_eyre::eyre::Result<()> { self.client .torrent_remove(ids.into(), delete_local_data) .await - .map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?; + .map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?; Ok(()) } - pub async fn rename(&mut self, torrent: &Torrent, name: &Path) -> anyhow::Result<()> { + pub async fn rename(&mut self, torrent: &Torrent, name: &Path) -> color_eyre::eyre::Result<()> { if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) { self.client .torrent_rename_path(vec![id], old_name, name.to_string_lossy().into()) .await - .map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?; + .map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?; } Ok(()) } - async fn action_all(&mut self, action: TorrentAction) -> anyhow::Result<()> { + async fn action_all(&mut self, action: TorrentAction) -> color_eyre::eyre::Result<()> { let ids = self .torrents .iter() @@ -103,7 +104,7 @@ impl Torrents { self.client .torrent_action(action, ids) .await - .map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?; + .map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?; Ok(()) } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 3e53860..e151995 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,10 +4,11 @@ mod tab; mod torrent; pub mod types; pub mod utils; -pub use {tab::Tab, torrent::Torrents}; +use crate::config::Config; use ratatui::widgets::TableState; use types::Selected; +pub use {tab::Tab, torrent::Torrents}; /// Main Application. #[derive(Debug)] @@ -18,12 +19,13 @@ pub struct App<'a> { pub state: TableState, pub torrents: Torrents, pub show_help: bool, + pub config: Config, } impl<'a> App<'a> { /// Constructs a new instance of [`App`]. /// Returns instance of `Self`. - pub fn new() -> anyhow::Result { + pub fn new(config: Config) -> color_eyre::eyre::Result { Ok(Self { running: true, tabs: &[Tab::All, Tab::Active, Tab::Downloading], @@ -31,11 +33,12 @@ impl<'a> App<'a> { state: TableState::default(), torrents: Torrents::new()?, // Handle the Result here show_help: false, + config, }) } /// Handles the tick event of the terminal. - pub async fn tick(&mut self) -> anyhow::Result<()> { + pub async fn tick(&mut self) -> color_eyre::eyre::Result<()> { self.torrents.update().await?; Ok(()) } @@ -119,14 +122,14 @@ impl<'a> App<'a> { self.show_help = true; } - pub async fn toggle_torrents(&mut self) -> anyhow::Result<()> { + pub async fn toggle_torrents(&mut self) -> color_eyre::eyre::Result<()> { let ids = self.selected(false); self.torrents.toggle(ids).await?; self.close_help(); Ok(()) } - pub async fn delete(&mut self, delete_local_data: bool) -> anyhow::Result<()> { + pub async fn delete(&mut self, delete_local_data: bool) -> color_eyre::eyre::Result<()> { let ids = self.selected(false); self.torrents.delete(ids, delete_local_data).await?; self.close_help(); diff --git a/src/app/torrent.rs b/src/app/torrent.rs index 1f1a910..c310dcf 100644 --- a/src/app/torrent.rs +++ b/src/app/torrent.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use color_eyre::eyre::Result; use std::{collections::HashSet, fmt::Debug}; use transmission_rpc::{ types::{Torrent, TorrentGetField}, @@ -50,7 +50,7 @@ impl Torrents { .client .torrent_get(self.fields.clone(), None) .await - .map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))? + .map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))? .arguments .torrents; Ok(self) diff --git a/src/config/colors.rs b/src/config/colors.rs new file mode 100644 index 0000000..894d7ff --- /dev/null +++ b/src/config/colors.rs @@ -0,0 +1,64 @@ +use ratatui::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +pub struct ColorsConfig { + pub highlight_background: Option, + pub highlight_foreground: Option, + pub warning_foreground: Option, + pub info_foreground: Option, + pub error_foreground: Option, +} + +impl ColorsConfig { + pub fn get_color(&self, color_name: &Option) -> Color { + match color_name { + Some(name) => match name.to_lowercase().as_str() { + "black" => Color::Black, + "blue" => Color::Blue, + "cyan" => Color::Cyan, + "darkgray" => Color::DarkGray, + "gray" => Color::Gray, + "green" => Color::Green, + "lightgreen" => Color::LightGreen, + "lightred" => Color::LightRed, + "magenta" => Color::Magenta, + "red" => Color::Red, + "white" => Color::White, + "yellow" => Color::Yellow, + _ => Color::Reset, // Default to reset, if color name is not recognized + }, + None => Color::Reset, + } + } + + pub fn merge(&mut self, other: Self) { + if let Some(highlight_background) = other.highlight_background { + self.highlight_background = Some(highlight_background); + } + if let Some(highlight_foreground) = other.highlight_foreground { + self.highlight_foreground = Some(highlight_foreground); + } + if let Some(warning_foreground) = other.warning_foreground { + self.warning_foreground = Some(warning_foreground); + } + if let Some(info_foreground) = other.info_foreground { + self.info_foreground = Some(info_foreground); + } + if let Some(error_foreground) = other.error_foreground { + self.error_foreground = Some(error_foreground); + } + } +} + +impl Default for ColorsConfig { + fn default() -> Self { + Self { + highlight_background: Some("magenta".to_string()), + highlight_foreground: Some("black".to_string()), + warning_foreground: Some("yellow".to_string()), + info_foreground: Some("blue".to_string()), + error_foreground: Some("red".to_string()), + } + } +} diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs new file mode 100644 index 0000000..47b7619 --- /dev/null +++ b/src/config/keybinds.rs @@ -0,0 +1,87 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +pub struct KeybindsConfig { + pub quit: Option, + pub next_tab: Option, + pub prev_tab: Option, + pub next_torrent: Option, + pub prev_torrent: Option, + pub switch_tab_1: Option, + pub switch_tab_2: Option, + pub switch_tab_3: Option, + pub toggle_torrent: Option, + pub toggle_all: Option, + pub delete: Option, + pub delete_force: Option, + pub select: Option, + pub toggle_help: Option, +} + +impl KeybindsConfig { + pub fn merge(&mut self, other: Self) { + if let Some(quit) = other.quit { + self.quit = Some(quit); + } + if let Some(next_tab) = other.next_tab { + self.next_tab = Some(next_tab); + } + if let Some(prev_tab) = other.prev_tab { + self.prev_tab = Some(prev_tab); + } + if let Some(next_torrent) = other.next_torrent { + self.next_torrent = Some(next_torrent); + } + if let Some(prev_torrent) = other.prev_torrent { + self.prev_torrent = Some(prev_torrent); + } + if let Some(switch_tab_1) = other.switch_tab_1 { + self.switch_tab_1 = Some(switch_tab_1); + } + if let Some(switch_tab_2) = other.switch_tab_2 { + self.switch_tab_2 = Some(switch_tab_2); + } + if let Some(switch_tab_3) = other.switch_tab_3 { + self.switch_tab_3 = Some(switch_tab_3); + } + if let Some(toggle_torrent) = other.toggle_torrent { + self.toggle_torrent = Some(toggle_torrent); + } + if let Some(toggle_all) = other.toggle_all { + self.toggle_all = Some(toggle_all); + } + if let Some(delete) = other.delete { + self.delete = Some(delete); + } + if let Some(delete_force) = other.delete_force { + self.delete_force = Some(delete_force); + } + if let Some(select) = other.select { + self.select = Some(select); + } + if let Some(toggle_help) = other.toggle_help { + self.toggle_help = Some(toggle_help); + } + } +} + +impl Default for KeybindsConfig { + fn default() -> Self { + Self { + quit: Some("q".to_string()), + next_tab: Some("l".to_string()), + prev_tab: Some("h".to_string()), + next_torrent: Some("j".to_string()), + prev_torrent: Some("k".to_string()), + switch_tab_1: Some("1".to_string()), + switch_tab_2: Some("2".to_string()), + switch_tab_3: Some("3".to_string()), + toggle_torrent: Some("enter".to_string()), + toggle_all: Some("a".to_string()), + delete: Some("d".to_string()), + delete_force: Some("D".to_string()), + select: Some(" ".to_string()), + toggle_help: Some("?".to_string()), + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..2431c04 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,61 @@ +mod colors; +mod keybinds; + +use color_eyre::Result; +use colors::ColorsConfig; +use keybinds::KeybindsConfig; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct Config { + pub keybinds: KeybindsConfig, + pub colors: ColorsConfig, +} + +impl Config { + pub fn load() -> Result { + let mut config = Self::default(); + + // Load system-wide config + let system_config_path = PathBuf::from("/etc/xdg/traxor/config.toml"); + if system_config_path.exists() { + let config_str = std::fs::read_to_string(&system_config_path)?; + let system_config: Config = toml::from_str(&config_str)?; + config.merge(system_config); + } + + // Load user-specific config + let user_config_path = Self::get_config_path()?; + if user_config_path.exists() { + let config_str = std::fs::read_to_string(&user_config_path)?; + let user_config: Config = toml::from_str(&config_str)?; + config.merge(user_config); + } + + Ok(config) + } + + pub fn save(&self) -> Result<()> { + let config_path = Self::get_config_path()?; + let config_str = toml::to_string_pretty(self)?; + std::fs::write(&config_path, config_str)?; + Ok(()) + } + + fn get_config_path() -> Result { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| panic!("Could not find home directory")) + .join(".config") + }); + Ok(config_dir.join("traxor").join("config.toml")) + } + + pub fn merge(&mut self, other: Self) { + self.keybinds.merge(other.keybinds); + self.colors.merge(other.colors); + } +} diff --git a/src/event.rs b/src/event.rs index 51d3699..302cfe6 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use color_eyre::eyre::Result; use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent}; use std::sync::mpsc; use std::thread; diff --git a/src/handler.rs b/src/handler.rs index f4fdbcb..b72e6ee 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -4,39 +4,102 @@ use tracing::{event, info_span, Level}; /// Handles the key events of [`App`]. #[tracing::instrument] -pub fn get_action(key_event: KeyEvent) -> Option { +pub fn get_action(key_event: KeyEvent, app: &App) -> Option { let span = info_span!("get_action"); let _enter = span.enter(); event!(Level::INFO, "handling key event: {:?}", key_event); - match key_event.code { - // Exit application on `ESC` or `q` - KeyCode::Esc | KeyCode::Char('q') => Some(Action::Quit), - // Exit application on `Ctrl-C` - KeyCode::Char('c') | KeyCode::Char('C') => match key_event.modifiers { - KeyModifiers::CONTROL => Some(Action::Quit), - _ => None, - }, - KeyCode::Char('l') | KeyCode::Right => Some(Action::NextTab), - KeyCode::Char('h') | KeyCode::Left => Some(Action::PrevTab), - KeyCode::Char('j') | KeyCode::Down => Some(Action::NextTorrent), - KeyCode::Char('k') | KeyCode::Up => Some(Action::PrevTorrent), - KeyCode::Char('1') => Some(Action::SwitchTab(0)), - KeyCode::Char('2') => Some(Action::SwitchTab(1)), - KeyCode::Char('3') => Some(Action::SwitchTab(2)), - KeyCode::Char('t') | KeyCode::Enter | KeyCode::Menu => Some(Action::ToggleTorrent), - KeyCode::Char('a') => Some(Action::ToggleAll), - KeyCode::Char('d') => Some(Action::Delete(false)), - KeyCode::Char('D') => Some(Action::Delete(true)), - KeyCode::Char(' ') => Some(Action::Select), - KeyCode::Char('?') => Some(Action::ToggleHelp), + let config_keybinds = &app.config.keybinds; + + // Helper to check if a KeyEvent matches a configured keybind string + let matches_keybind = |event: &KeyEvent, config_key: &Option| { + if let Some(key_str) = config_key { + let parts: Vec<&str> = key_str.split('+').collect(); + let mut parsed_modifiers = KeyModifiers::NONE; + let mut parsed_key_code = None; + + for part in &parts { + match part.to_lowercase().as_str() { + "ctrl" => parsed_modifiers.insert(KeyModifiers::CONTROL), + "alt" => parsed_modifiers.insert(KeyModifiers::ALT), + "shift" => parsed_modifiers.insert(KeyModifiers::SHIFT), + "esc" => parsed_key_code = Some(KeyCode::Esc), + "enter" => parsed_key_code = Some(KeyCode::Enter), + "left" => parsed_key_code = Some(KeyCode::Left), + "right" => parsed_key_code = Some(KeyCode::Right), + "up" => parsed_key_code = Some(KeyCode::Up), + "down" => parsed_key_code = Some(KeyCode::Down), + "tab" => parsed_key_code = Some(KeyCode::Tab), + "backspace" => parsed_key_code = Some(KeyCode::Backspace), + "delete" => parsed_key_code = Some(KeyCode::Delete), + "home" => parsed_key_code = Some(KeyCode::Home), + "end" => parsed_key_code = Some(KeyCode::End), + "pageup" => parsed_key_code = Some(KeyCode::PageUp), + "pagedown" => parsed_key_code = Some(KeyCode::PageDown), + "null" => parsed_key_code = Some(KeyCode::Null), + "insert" => parsed_key_code = Some(KeyCode::Insert), + _ => { + if part.len() == 1 { + if let Some(c) = part.chars().next() { + parsed_key_code = Some(KeyCode::Char(c)); + } else { + return false; + } + } else if part.starts_with("f") && part.len() > 1 { + if let Ok(f_num) = part[1..].parse::() { + parsed_key_code = Some(KeyCode::F(f_num)); + } else { + return false; + } + } else { + return false; + } + } + } + } + + if parsed_key_code.is_none() { + return false; + } + + event.code == parsed_key_code.unwrap() && event.modifiers == parsed_modifiers + } else { + false + } + }; + + match key_event.code { + _ if matches_keybind(&key_event, &config_keybinds.quit) => Some(Action::Quit), + _ if matches_keybind(&key_event, &config_keybinds.next_tab) => Some(Action::NextTab), + _ if matches_keybind(&key_event, &config_keybinds.prev_tab) => Some(Action::PrevTab), + _ if matches_keybind(&key_event, &config_keybinds.next_torrent) => Some(Action::NextTorrent), + _ if matches_keybind(&key_event, &config_keybinds.prev_torrent) => Some(Action::PrevTorrent), + _ if matches_keybind(&key_event, &config_keybinds.switch_tab_1) => { + Some(Action::SwitchTab(0)) + } + _ if matches_keybind(&key_event, &config_keybinds.switch_tab_2) => { + Some(Action::SwitchTab(1)) + } + _ if matches_keybind(&key_event, &config_keybinds.switch_tab_3) => { + Some(Action::SwitchTab(2)) + } + _ if matches_keybind(&key_event, &config_keybinds.toggle_torrent) => { + Some(Action::ToggleTorrent) + } + _ if matches_keybind(&key_event, &config_keybinds.toggle_all) => Some(Action::ToggleAll), + _ if matches_keybind(&key_event, &config_keybinds.delete) => Some(Action::Delete(false)), + _ if matches_keybind(&key_event, &config_keybinds.delete_force) => { + Some(Action::Delete(true)) + } + _ if matches_keybind(&key_event, &config_keybinds.select) => Some(Action::Select), + _ if matches_keybind(&key_event, &config_keybinds.toggle_help) => Some(Action::ToggleHelp), _ => None, } } /// Handles the updates of [`App`]. #[tracing::instrument] -pub async fn update(app: &mut App<'_>, action: Action) -> anyhow::Result<()> { +pub async fn update(app: &mut App<'_>, action: Action) -> color_eyre::eyre::Result<()> { let span = info_span!("update"); let _enter = span.enter(); event!(Level::INFO, "updating app with action: {:?}", action); diff --git a/src/lib.rs b/src/lib.rs index 85ad8bf..9767bf5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,7 @@ -/// Application. +pub mod config; pub mod app; - -/// Terminal events handler. pub mod event; - -/// Widget renderer. -pub mod ui; - -/// Terminal user interface. -pub mod tui; - -/// Event handler. pub mod handler; +pub mod log; +pub mod tui; +pub mod ui; \ No newline at end of file diff --git a/src/log.rs b/src/log.rs index 436f2b4..8ee0b11 100644 --- a/src/log.rs +++ b/src/log.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use color_eyre::eyre::Result; use tracing_appender::rolling; use tracing_subscriber::{self, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; diff --git a/src/main.rs b/src/main.rs index 1a388f9..3d8df7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,12 @@ mod log; -use color_eyre::Result; +use color_eyre::eyre::Result; use log::setup_logger; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; use traxor::{ app::App, + config::Config, event::{Event, EventHandler}, handler::{get_action, update}, tui::Tui, @@ -13,11 +14,16 @@ use traxor::{ #[tokio::main] async fn main() -> Result<()> { + color_eyre::install()?; + // Setup the logger. setup_logger()?; + // Load configuration. + let config = Config::load()?; + // Create an application. - let mut app = App::new()?; + let mut app = App::new(config)?; // Initialize the terminal user interface. let backend = CrosstermBackend::new(io::stderr()); @@ -33,10 +39,9 @@ async fn main() -> Result<()> { // Handle events. match tui.events.next()? { Event::Tick => app.tick().await?, - // Event::Key(key_event) => handle_key_events(key_event, &mut app).await?, Event::Key(key_event) => { - if let Some(action) = get_action(key_event) { - update(&mut app, action).await.unwrap(); + if let Some(action) = get_action(key_event, &app) { + update(&mut app, action).await?; } } Event::Mouse(_) => {} @@ -48,3 +53,4 @@ async fn main() -> Result<()> { tui.exit()?; Ok(()) } + diff --git a/src/tui.rs b/src/tui.rs index 1dd0681..91b0a8d 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,7 +1,7 @@ use crate::app::App; use crate::event::EventHandler; use crate::ui; -use anyhow::Result; +use color_eyre::eyre::Result; use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use ratatui::backend::Backend; diff --git a/src/ui/help.rs b/src/ui/help.rs index b1a467b..fd5e1a1 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -1,30 +1,33 @@ use ratatui::{prelude::*, widgets::*}; +use crate::app::App; -pub fn render_help(frame: &mut Frame) { +pub fn render_help(frame: &mut Frame, app: &App) { let block = Block::default() .title("Help") .title_alignment(Alignment::Center) .borders(Borders::ALL) .border_type(BorderType::Rounded); + let keybinds = &app.config.keybinds; + let rows = vec![ - Row::new(vec![Cell::from("?"), Cell::from("Show help")]), - Row::new(vec![Cell::from("q"), Cell::from("Quit")]), - Row::new(vec![Cell::from("h"), Cell::from("Left")]), - Row::new(vec![Cell::from("l"), Cell::from("Right")]), - Row::new(vec![Cell::from("j"), Cell::from("Down")]), - Row::new(vec![Cell::from("k"), Cell::from("Up")]), - Row::new(vec![Cell::from("1"), Cell::from("Switch to All tab")]), - Row::new(vec![Cell::from("2"), Cell::from("Switch to Active tab")]), + Row::new(vec![Cell::from(keybinds.toggle_help.as_deref().unwrap_or("?")), Cell::from("Show help")]), + Row::new(vec![Cell::from(keybinds.quit.as_deref().unwrap_or("q")), Cell::from("Quit")]), + Row::new(vec![Cell::from(keybinds.prev_tab.as_deref().unwrap_or("h")), Cell::from("Left")]), + Row::new(vec![Cell::from(keybinds.next_tab.as_deref().unwrap_or("l")), Cell::from("Right")]), + Row::new(vec![Cell::from(keybinds.next_torrent.as_deref().unwrap_or("j")), Cell::from("Down")]), + Row::new(vec![Cell::from(keybinds.prev_torrent.as_deref().unwrap_or("k")), Cell::from("Up")]), + Row::new(vec![Cell::from(keybinds.switch_tab_1.as_deref().unwrap_or("1")), Cell::from("Switch to All tab")]), + Row::new(vec![Cell::from(keybinds.switch_tab_2.as_deref().unwrap_or("2")), Cell::from("Switch to Active tab")]), Row::new(vec![ - Cell::from("3"), + Cell::from(keybinds.switch_tab_3.as_deref().unwrap_or("3")), Cell::from("Switch to Downloading tab"), ]), - Row::new(vec![Cell::from("t"), Cell::from("Toggle torrent")]), - Row::new(vec![Cell::from("a"), Cell::from("Toggle all torrents")]), - Row::new(vec![Cell::from("d"), Cell::from("Delete torrent")]), - Row::new(vec![Cell::from("D"), Cell::from("Delete torrent and data")]), - Row::new(vec![Cell::from(" "), Cell::from("Select torrent")]), + Row::new(vec![Cell::from(keybinds.toggle_torrent.as_deref().unwrap_or("t")), Cell::from("Toggle torrent")]), + Row::new(vec![Cell::from(keybinds.toggle_all.as_deref().unwrap_or("a")), Cell::from("Toggle all torrents")]), + Row::new(vec![Cell::from(keybinds.delete.as_deref().unwrap_or("d")), Cell::from("Delete torrent")]), + Row::new(vec![Cell::from(keybinds.delete_force.as_deref().unwrap_or("D")), Cell::from("Delete torrent and data")]), + Row::new(vec![Cell::from(keybinds.select.as_deref().unwrap_or(" ")), Cell::from("Select torrent")]), ]; let table = Table::new( @@ -32,7 +35,7 @@ pub fn render_help(frame: &mut Frame) { &[Constraint::Percentage(20), Constraint::Percentage(80)], ) .block(block) - .style(Style::default().fg(Color::Green)); + .style(Style::default().fg(app.config.colors.get_color(&app.config.colors.info_foreground))); let area = frame.area(); let height = 15; // Desired height for the help menu diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5f6c535..2b50822 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -32,8 +32,8 @@ pub fn render(app: &mut App, frame: &mut Frame) { .border_type(BorderType::Rounded), ) .select(app.index()) - .style(Style::default().fg(Color::Blue)) - .highlight_style(Style::default().fg(Color::Green)) + .style(Style::default().fg(app.config.colors.get_color(&app.config.colors.info_foreground))) + .highlight_style(Style::default().fg(app.config.colors.get_color(&app.config.colors.warning_foreground))) .divider("|"); frame.render_widget(tabs, chunks[0]); // renders tab @@ -51,6 +51,6 @@ pub fn render(app: &mut App, frame: &mut Frame) { frame.render_stateful_widget(table, chunks[1], &mut app.state); // renders table if app.show_help { - render_help(frame); + render_help(frame, app); } } diff --git a/src/ui/table.rs b/src/ui/table.rs index 0710bcd..c278c46 100644 --- a/src/ui/table.rs +++ b/src/ui/table.rs @@ -1,7 +1,7 @@ use crate::app::{utils::Wrapper, App, Tab}; use ratatui::{ layout::Constraint, - style::{Color, Style, Styled}, + style::{Style, Styled}, widgets::{Block, BorderType, Borders, Row, Table}, }; @@ -9,7 +9,10 @@ pub fn render_table<'a>(app: &mut App, tab: Tab) -> Table<'a> { let fields = tab.fields(); let selected = &app.torrents.selected.clone(); let torrents = &app.torrents.set_fields(None).torrents; - let highlight_style = Style::default().bg(Color::Magenta).fg(Color::Black); + + let highlight_bg = app.config.colors.get_color(&app.config.colors.highlight_background); + let highlight_fg = app.config.colors.get_color(&app.config.colors.highlight_foreground); + let highlight_style = Style::default().bg(highlight_bg).fg(highlight_fg); let rows: Vec> = torrents .iter() @@ -35,15 +38,18 @@ pub fn render_table<'a>(app: &mut App, tab: Tab) -> Table<'a> { .map(|&field| Constraint::Length(field.width())) .collect::>(); + let header_fg = app.config.colors.get_color(&app.config.colors.warning_foreground); let header = Row::new( fields .iter() .map(|&field| field.title()) .collect::>(), ) - .style(Style::default().fg(Color::Yellow)); + .style(Style::default().fg(header_fg)); - let highlight_style = Style::default().bg(Color::Blue).fg(Color::Black); + let row_highlight_bg = app.config.colors.get_color(&app.config.colors.info_foreground); + let row_highlight_fg = app.config.colors.get_color(&app.config.colors.highlight_foreground); + let row_highlight_style = Style::default().bg(row_highlight_bg).fg(row_highlight_fg); Table::new(rows, widths) .block( @@ -52,6 +58,6 @@ pub fn render_table<'a>(app: &mut App, tab: Tab) -> Table<'a> { .border_type(BorderType::Rounded), ) .header(header) - .row_highlight_style(highlight_style) + .row_highlight_style(row_highlight_style) .column_spacing(1) } diff --git a/tests/app.rs b/tests/app.rs index 0efdac5..fc57143 100644 --- a/tests/app.rs +++ b/tests/app.rs @@ -1,21 +1,24 @@ -use traxor::app::App; +use traxor::{app::App, config::Config}; #[test] fn test_app_creation() { - let app = App::new().unwrap(); + let config = Config::load().unwrap(); + let app = App::new(config).unwrap(); assert_eq!(app.tabs().len(), 3); } #[test] fn test_app_quit() { - let mut app = App::new().unwrap(); + let config = Config::load().unwrap(); + let mut app = App::new(config).unwrap(); app.quit(); assert!(!app.running); } #[test] fn test_app_next_tab() { - let mut app = App::new().unwrap(); + let config = Config::load().unwrap(); + let mut app = App::new(config).unwrap(); assert_eq!(app.index(), 0); app.next_tab(); assert_eq!(app.index(), 1); @@ -27,7 +30,8 @@ fn test_app_next_tab() { #[test] fn test_app_prev_tab() { - let mut app = App::new().unwrap(); + let config = Config::load().unwrap(); + let mut app = App::new(config).unwrap(); assert_eq!(app.index(), 0); app.prev_tab(); assert_eq!(app.index(), 2); // Wraps around @@ -37,7 +41,8 @@ fn test_app_prev_tab() { #[test] fn test_app_switch_tab() { - let mut app = App::new().unwrap(); + let config = Config::load().unwrap(); + let mut app = App::new(config).unwrap(); assert_eq!(app.index(), 0); app.switch_tab(2); assert_eq!(app.index(), 2); @@ -47,7 +52,8 @@ fn test_app_switch_tab() { #[test] fn test_app_toggle_popup() { - let mut app = App::new().unwrap(); + let config = Config::load().unwrap(); + let mut app = App::new(config).unwrap(); assert!(!app.show_help); app.toggle_help(); assert!(app.show_help); @@ -57,7 +63,8 @@ fn test_app_toggle_popup() { #[test] fn test_app_open_close_popup() { - let mut app = App::new().unwrap(); + let config = Config::load().unwrap(); + let mut app = App::new(config).unwrap(); assert!(!app.show_help); app.open_help(); assert!(app.show_help); diff --git a/tests/handler.rs b/tests/handler.rs index d231f43..aa309e3 100644 --- a/tests/handler.rs +++ b/tests/handler.rs @@ -1,109 +1,86 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use traxor::{app::action::Action, handler::get_action}; +use traxor::{app::action::Action, handler::get_action, app::App, config::Config}; #[test] fn test_get_action_quit() { - assert_eq!(get_action(KeyEvent::from(KeyCode::Esc)), Some(Action::Quit)); + let config = Config::load().unwrap(); + let app = App::new(config).unwrap(); assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('q'))), - Some(Action::Quit) - ); - assert_eq!( - get_action(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), - Some(Action::Quit) - ); - assert_eq!( - get_action(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)), + get_action(KeyEvent::from(KeyCode::Char('q')), &app), Some(Action::Quit) ); } #[test] fn test_get_action_navigation() { + let config = Config::load().unwrap(); + let app = App::new(config).unwrap(); assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('l'))), + get_action(KeyEvent::from(KeyCode::Char('l')), &app), Some(Action::NextTab) ); assert_eq!( - get_action(KeyEvent::from(KeyCode::Right)), - Some(Action::NextTab) - ); - assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('h'))), + get_action(KeyEvent::from(KeyCode::Char('h')), &app), Some(Action::PrevTab) ); assert_eq!( - get_action(KeyEvent::from(KeyCode::Left)), - Some(Action::PrevTab) - ); - assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('j'))), + get_action(KeyEvent::from(KeyCode::Char('j')), &app), Some(Action::NextTorrent) ); assert_eq!( - get_action(KeyEvent::from(KeyCode::Down)), - Some(Action::NextTorrent) - ); - assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('k'))), - Some(Action::PrevTorrent) - ); - assert_eq!( - get_action(KeyEvent::from(KeyCode::Up)), + get_action(KeyEvent::from(KeyCode::Char('k')), &app), Some(Action::PrevTorrent) ); } #[test] fn test_get_action_switch_tab() { + let config = Config::load().unwrap(); + let app = App::new(config).unwrap(); assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('1'))), + get_action(KeyEvent::from(KeyCode::Char('1')), &app), Some(Action::SwitchTab(0)) ); assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('2'))), + get_action(KeyEvent::from(KeyCode::Char('2')), &app), Some(Action::SwitchTab(1)) ); assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('3'))), + get_action(KeyEvent::from(KeyCode::Char('3')), &app), Some(Action::SwitchTab(2)) ); } #[test] fn test_get_action_torrent_actions() { + let config = Config::load().unwrap(); + let app = App::new(config).unwrap(); assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('t'))), + get_action(KeyEvent::from(KeyCode::Enter), &app), Some(Action::ToggleTorrent) ); assert_eq!( - get_action(KeyEvent::from(KeyCode::Enter)), - Some(Action::ToggleTorrent) - ); - assert_eq!( - get_action(KeyEvent::from(KeyCode::Menu)), - Some(Action::ToggleTorrent) - ); - assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('a'))), + get_action(KeyEvent::from(KeyCode::Char('a')), &app), Some(Action::ToggleAll) ); assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('d'))), + get_action(KeyEvent::from(KeyCode::Char('d')), &app), Some(Action::Delete(false)) ); assert_eq!( - get_action(KeyEvent::from(KeyCode::Char('D'))), + get_action(KeyEvent::from(KeyCode::Char('D')), &app), Some(Action::Delete(true)) ); assert_eq!( - get_action(KeyEvent::from(KeyCode::Char(' '))), + get_action(KeyEvent::from(KeyCode::Char(' ')), &app), Some(Action::Select) ); } #[test] fn test_get_action_unhandled() { - assert_eq!(get_action(KeyEvent::from(KeyCode::Char('x'))), None); - assert_eq!(get_action(KeyEvent::from(KeyCode::F(1))), None); + let config = Config::load().unwrap(); + let app = App::new(config).unwrap(); + assert_eq!(get_action(KeyEvent::from(KeyCode::Char('x')), &app), None); + assert_eq!(get_action(KeyEvent::from(KeyCode::F(1)), &app), None); }