feat(config): add config file

This commit is contained in:
Kristofers Solo 2025-07-07 19:18:18 +03:00
parent f393ae8a70
commit 06fa7c003d
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
20 changed files with 562 additions and 146 deletions

120
Cargo.lock generated
View File

@ -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"

View File

@ -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"

22
config/default.toml Normal file
View File

@ -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"

View File

@ -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<bool>,
) -> 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(())
}
}

View File

@ -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<Self> {
pub fn new(config: Config) -> color_eyre::eyre::Result<Self> {
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();

View File

@ -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)

64
src/config/colors.rs Normal file
View File

@ -0,0 +1,64 @@
use ratatui::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct ColorsConfig {
pub highlight_background: Option<String>,
pub highlight_foreground: Option<String>,
pub warning_foreground: Option<String>,
pub info_foreground: Option<String>,
pub error_foreground: Option<String>,
}
impl ColorsConfig {
pub fn get_color(&self, color_name: &Option<String>) -> 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()),
}
}
}

87
src/config/keybinds.rs Normal file
View File

@ -0,0 +1,87 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct KeybindsConfig {
pub quit: Option<String>,
pub next_tab: Option<String>,
pub prev_tab: Option<String>,
pub next_torrent: Option<String>,
pub prev_torrent: Option<String>,
pub switch_tab_1: Option<String>,
pub switch_tab_2: Option<String>,
pub switch_tab_3: Option<String>,
pub toggle_torrent: Option<String>,
pub toggle_all: Option<String>,
pub delete: Option<String>,
pub delete_force: Option<String>,
pub select: Option<String>,
pub toggle_help: Option<String>,
}
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()),
}
}
}

61
src/config/mod.rs Normal file
View File

@ -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<Self> {
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<PathBuf> {
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);
}
}

View File

@ -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;

View File

@ -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<Action> {
pub fn get_action(key_event: KeyEvent, app: &App) -> Option<Action> {
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<String>| {
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::<u8>() {
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);

View File

@ -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;

View File

@ -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};

View File

@ -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(())
}

View File

@ -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;

View File

@ -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

View File

@ -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);
}
}

View File

@ -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<Row<'_>> = 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::<Vec<_>>();
let header_fg = app.config.colors.get_color(&app.config.colors.warning_foreground);
let header = Row::new(
fields
.iter()
.map(|&field| field.title())
.collect::<Vec<_>>(),
)
.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)
}

View File

@ -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);

View File

@ -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);
}