refactor: add custom error type
Some checks are pending
CI / build-and-test (push) Waiting to run

This commit is contained in:
Kristofers Solo 2025-12-10 03:57:28 +02:00
parent 3abeb83660
commit be542551f3
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
13 changed files with 234 additions and 179 deletions

28
Cargo.lock generated
View File

@ -199,9 +199,9 @@ dependencies = [
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.7.1" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
@ -316,22 +316,23 @@ dependencies = [
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.0.1" version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618"
dependencies = [ dependencies = [
"derive_more-impl", "derive_more-impl",
] ]
[[package]] [[package]]
name = "derive_more-impl" name = "derive_more-impl"
version = "2.0.1" version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b"
dependencies = [ dependencies = [
"convert_case", "convert_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustc_version",
"syn", "syn",
"unicode-xid", "unicode-xid",
] ]
@ -1414,6 +1415,15 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "0.38.44" version = "0.38.44"
@ -1493,6 +1503,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.219" version = "1.0.219"

View File

@ -9,7 +9,7 @@ edition = "2024"
filecaster = { version = "0.2", features = ["derive", "merge"] } filecaster = { version = "0.2", features = ["derive", "merge"] }
color-eyre = "0.6" color-eyre = "0.6"
crossterm = "0.29" crossterm = "0.29"
derive_more = { version = "2.0", features = ["display"] } derive_more = { version = "2.1", features = ["display"] }
dirs = "6.0" dirs = "6.0"
merge = "0.2" merge = "0.2"
ratatui = { version = "0.29" } ratatui = { version = "0.29" }

View File

@ -1,5 +1,5 @@
use super::{Torrents, types::Selected}; use super::{Torrents, types::Selected};
use color_eyre::{Result, eyre::eyre}; use crate::error::Result;
use std::{collections::HashSet, path::Path}; use std::{collections::HashSet, path::Path};
use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus}; use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus};
@ -9,11 +9,11 @@ impl Torrents {
/// TODO: add error types /// TODO: add error types
pub async fn toggle(&mut self, ids: Selected) -> Result<()> { pub async fn toggle(&mut self, ids: Selected) -> Result<()> {
let ids: HashSet<_> = ids.into(); let ids: HashSet<_> = ids.into();
let torrents_to_toggle: Vec<_> = self let torrents_to_toggle = self
.torrents .torrents
.iter() .iter()
.filter(|torrent| torrent.id.is_some_and(|id| ids.contains(&id))) .filter(|torrent| torrent.id.is_some_and(|id| ids.contains(&id)))
.collect(); .collect::<Vec<_>>();
for torrent in torrents_to_toggle { for torrent in torrents_to_toggle {
let action = match torrent.status { let action = match torrent.status {
@ -21,10 +21,7 @@ impl Torrents {
_ => TorrentAction::Stop, _ => TorrentAction::Stop,
}; };
if let Some(id) = torrent.id() { if let Some(id) = torrent.id() {
self.client self.client.torrent_action(action, vec![id]).await?;
.torrent_action(action, vec![id])
.await
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
} }
} }
Ok(()) Ok(())
@ -51,10 +48,7 @@ impl Torrents {
.collect(); .collect();
for (id, action) in torrents_to_toggle { for (id, action) in torrents_to_toggle {
self.client self.client.torrent_action(action, vec![id]).await?;
.torrent_action(action, vec![id])
.await
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
} }
Ok(()) Ok(())
} }
@ -85,8 +79,7 @@ impl Torrents {
if let Some(id) = torrent.id() { if let Some(id) = torrent.id() {
self.client self.client
.torrent_set_location(vec![id], location.to_string_lossy().into(), move_from) .torrent_set_location(vec![id], location.to_string_lossy().into(), move_from)
.await .await?;
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
} }
Ok(()) Ok(())
} }
@ -97,8 +90,7 @@ impl Torrents {
pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> Result<()> { pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> Result<()> {
self.client self.client
.torrent_remove(ids.into(), delete_local_data) .torrent_remove(ids.into(), delete_local_data)
.await .await?;
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
Ok(()) Ok(())
} }
@ -109,8 +101,7 @@ impl Torrents {
if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) { if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) {
self.client self.client
.torrent_rename_path(vec![id], old_name, name.to_string_lossy().into()) .torrent_rename_path(vec![id], old_name, name.to_string_lossy().into())
.await .await?;
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
} }
Ok(()) Ok(())
} }
@ -125,10 +116,7 @@ impl Torrents {
.filter_map(Torrent::id) .filter_map(Torrent::id)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
self.client self.client.torrent_action(action, ids).await?;
.torrent_action(action, ids)
.await
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
Ok(()) Ok(())
} }
} }

7
src/app/constants.rs Normal file
View File

@ -0,0 +1,7 @@
pub const DEFAULT_TICK_RATE_MS: u64 = 250;
pub const TORRENT_UPDATE_INTERVAL_SECS: u64 = 2;
pub const DEFAULT_RPC_URL: &str = "http://localhost:9091/transmission/rpc";
pub const HELP_POPUP_HEIGHT: u16 = 15;
pub const INPUT_WIDTH_DIVISOR: u16 = 4;
pub const TAB_COUNT: usize = 3;

102
src/app/input.rs Normal file
View File

@ -0,0 +1,102 @@
use crate::error::Result;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Default)]
pub struct InputHandler {
pub text: String,
pub cursor_position: usize,
pub completions: Vec<String>,
pub completion_idx: usize,
}
impl InputHandler {
pub fn new() -> Self {
Self::default()
}
pub fn insert_char(&mut self, ch: char) {
self.text.insert(self.cursor_position, ch);
self.cursor_position += 1;
}
pub fn delete_char(&mut self) {
if self.cursor_position > 0 {
self.cursor_position -= 1;
self.text.remove(self.cursor_position);
}
}
pub fn clear(&mut self) {
self.text.clear();
self.cursor_position = 0;
self.completions.clear();
self.completion_idx = 0;
}
pub fn set_text(&mut self, text: String) {
self.cursor_position = text.len();
self.text = text;
}
pub async fn complete(&mut self) -> Result<()> {
let path = PathBuf::from(&self.text);
let (base_path, partial_name) = split_path_components(path);
let matches = find_matching_entries(&base_path, &partial_name).await?;
self.update_completions(matches);
self.update_from_completions();
Ok(())
}
fn update_completions(&mut self, matches: Vec<String>) {
if matches.is_empty() {
self.completions.clear();
self.completion_idx = 0;
} else if matches != self.completions {
self.completions = matches;
self.completion_idx = 0;
} else {
self.completion_idx = (self.completion_idx + 1) % self.completions.len();
}
}
fn update_from_completions(&mut self) {
if let Some(completions) = self.completions.get(self.completion_idx) {
self.set_text(completions.clone());
}
}
}
fn split_path_components(path: PathBuf) -> (PathBuf, String) {
if path.is_dir() {
return (path, String::new());
}
let partial = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let base = path
.parent()
.unwrap_or_else(|| Path::new("/"))
.to_path_buf();
(base, partial)
}
async fn find_matching_entries(base_path: &Path, partial_name: &str) -> Result<Vec<String>> {
let mut entries = fs::read_dir(&base_path).await?;
let mut matches = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name
.to_lowercase()
.starts_with(&partial_name.to_lowercase())
{
matches.push(format!("{}/{}", base_path.to_string_lossy(), file_name));
}
}
Ok(matches)
}

View File

@ -1,36 +1,34 @@
pub mod action; pub mod action;
mod command; mod command;
pub mod constants;
mod input;
mod tab; mod tab;
mod torrent; mod torrent;
pub mod types; pub mod types;
pub mod utils; pub mod utils;
use crate::config::Config; use crate::error::Result;
use color_eyre::Result; use crate::{app::input::InputHandler, config::Config};
use ratatui::widgets::TableState; use ratatui::widgets::TableState;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use tokio::fs;
use types::Selected; use types::Selected;
pub use {tab::Tab, torrent::Torrents}; pub use {tab::Tab, torrent::Torrents};
/// Main Application. /// Main Application.
#[derive(Debug)] #[derive(Debug)]
pub struct App<'a> { pub struct App {
pub running: bool, pub running: bool,
index: usize, index: usize,
tabs: &'a [Tab], tabs: Vec<Tab>,
pub state: TableState, pub state: TableState,
pub torrents: Torrents, pub torrents: Torrents,
pub show_help: bool, pub show_help: bool,
pub config: Config, pub config: Config,
pub input: String, pub input_handler: InputHandler,
pub cursor_position: usize,
pub input_mode: bool, pub input_mode: bool,
pub completions: Vec<String>,
pub completion_idx: usize,
} }
impl App<'_> { impl App {
/// Constructs a new instance of [`App`]. /// Constructs a new instance of [`App`].
/// Returns instance of `Self`. /// Returns instance of `Self`.
/// ///
@ -40,17 +38,14 @@ impl App<'_> {
pub fn new(config: Config) -> Result<Self> { pub fn new(config: Config) -> Result<Self> {
Ok(Self { Ok(Self {
running: true, running: true,
tabs: &[Tab::All, Tab::Active, Tab::Downloading], tabs: vec![Tab::All, Tab::Active, Tab::Downloading],
index: 0, index: 0,
state: TableState::default(), state: TableState::default(),
torrents: Torrents::new()?, // Handle the Result here torrents: Torrents::new()?, // Handle the Result here
show_help: false, show_help: false,
config, config,
input: String::new(), input_handler: InputHandler::new(),
cursor_position: 0,
input_mode: false, input_mode: false,
completions: Vec::new(),
completion_idx: 0,
}) })
} }
@ -58,14 +53,7 @@ impl App<'_> {
/// ///
/// TODO: add error types /// TODO: add error types
pub async fn complete_input(&mut self) -> Result<()> { pub async fn complete_input(&mut self) -> Result<()> {
let path = PathBuf::from(&self.input); self.input_handler.complete().await
let (base_path, partial_name) = split_path_components(path);
let matches = find_matching_entries(&base_path, &partial_name).await?;
self.update_completions(matches);
self.update_input_with_matches();
Ok(())
} }
/// Handles the tick event of the terminal. /// Handles the tick event of the terminal.
@ -149,8 +137,8 @@ impl App<'_> {
/// Returns [`Tab`] slice /// Returns [`Tab`] slice
#[inline] #[inline]
#[must_use] #[must_use]
pub const fn tabs(&self) -> &[Tab] { pub fn tabs(&self) -> &[Tab] {
self.tabs &self.tabs
} }
#[inline] #[inline]
@ -192,16 +180,18 @@ impl App<'_> {
/// ///
/// TODO: add error types /// TODO: add error types
pub async fn move_torrent(&mut self) -> Result<()> { pub async fn move_torrent(&mut self) -> Result<()> {
self.torrents.move_selection(&self.input).await?; self.torrents
self.input.clear(); .move_selection(&self.input_handler.text)
self.cursor_position = 0; .await?;
self.input_handler.clear();
self.input_mode = false; self.input_mode = false;
Ok(()) Ok(())
} }
pub fn prepare_move_action(&mut self) { pub fn prepare_move_action(&mut self) {
if let Some(download_dir) = self.get_current_downlaod_dir() { if let Some(download_dir) = self.get_current_downlaod_dir() {
self.update_cursor(&download_dir); self.input_handler
.set_text(download_dir.to_string_lossy().to_string());
} }
self.input_mode = true; self.input_mode = true;
} }
@ -235,29 +225,6 @@ impl App<'_> {
Selected::List(selected_torrents) Selected::List(selected_torrents)
} }
fn update_completions(&mut self, matches: Vec<String>) {
if matches.is_empty() {
self.completions.clear();
self.completion_idx = 0;
return;
}
if matches != self.completions {
self.completions = matches;
self.completion_idx = 0;
return;
}
self.completion_idx = (self.completion_idx + 1) % self.completions.len();
}
fn update_input_with_matches(&mut self) {
if let Some(completion) = self.completions.get(self.completion_idx) {
self.input = completion.clone();
self.cursor_position = self.input.len();
}
}
fn get_current_downlaod_dir(&self) -> Option<PathBuf> { fn get_current_downlaod_dir(&self) -> Option<PathBuf> {
match self.selected(true) { match self.selected(true) {
Selected::Current(current_id) => self Selected::Current(current_id) => self
@ -270,46 +237,4 @@ impl App<'_> {
Selected::List(_) => None, Selected::List(_) => None,
} }
} }
fn update_cursor(&mut self, path: &Path) {
self.input = path.to_string_lossy().to_string();
self.cursor_position = self.input.len();
}
}
fn split_path_components(path: PathBuf) -> (PathBuf, String) {
if path.is_dir() {
return (path, String::new());
}
let partial = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let base = path
.parent()
.unwrap_or_else(|| Path::new("/"))
.to_path_buf();
(base, partial)
}
async fn find_matching_entries(base_path: &Path, partial_name: &str) -> Result<Vec<String>> {
let mut entries = fs::read_dir(&base_path).await?;
let mut matches = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name
.to_lowercase()
.starts_with(&partial_name.to_lowercase())
{
matches.push(format!("{}/{}", base_path.to_string_lossy(), file_name));
}
}
Ok(matches)
} }

View File

@ -2,7 +2,7 @@ use std::fmt::Display;
use transmission_rpc::types::TorrentGetField; use transmission_rpc::types::TorrentGetField;
/// Available tabs. /// Available tabs.
#[derive(Debug, Default)] #[derive(Debug, Clone, Default)]
pub enum Tab { pub enum Tab {
#[default] #[default]
All, All,

View File

@ -1,4 +1,4 @@
use color_eyre::{Result, eyre::eyre}; use crate::{app::constants::DEFAULT_RPC_URL, error::Result};
use std::{collections::HashSet, fmt::Debug}; use std::{collections::HashSet, fmt::Debug};
use transmission_rpc::{ use transmission_rpc::{
TransClient, TransClient,
@ -22,7 +22,7 @@ impl Torrents {
/// ///
/// TODO: add error types /// TODO: add error types
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
let url = Url::parse("http://localhost:9091/transmission/rpc")?; let url = Url::parse(DEFAULT_RPC_URL)?;
Ok(Self { Ok(Self {
client: TransClient::new(url), client: TransClient::new(url),
torrents: Vec::new(), torrents: Vec::new(),
@ -70,8 +70,7 @@ impl Torrents {
self.torrents = self self.torrents = self
.client .client
.torrent_get(self.fields.clone(), None) .torrent_get(self.fields.clone(), None)
.await .await?
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?
.arguments .arguments
.torrents; .torrents;
Ok(self) Ok(self)
@ -84,8 +83,7 @@ impl Torrents {
let ids: Vec<Id> = self.selected.iter().map(|id| Id::Id(*id)).collect(); let ids: Vec<Id> = self.selected.iter().map(|id| Id::Id(*id)).collect();
self.client self.client
.torrent_set_location(ids, location.to_string(), Some(true)) .torrent_set_location(ids, location.to_string(), Some(true))
.await .await?;
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
Ok(()) Ok(())
} }
} }

30
src/error.rs Normal file
View File

@ -0,0 +1,30 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TraxorError {
#[error("Transmission RPC error: {0}")]
TransmissionRpc(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("URL parse error: {0}")]
UrlParse(#[from] url::ParseError),
#[error("No torrent selected")]
NoSelection,
#[error("Invalid torrent ID: {0}")]
InvalidTorrentId(i64),
}
impl From<Box<dyn std::error::Error + Send + Sync>> for TraxorError {
fn from(e: Box<dyn std::error::Error + Send + Sync>) -> Self {
Self::TransmissionRpc(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, TraxorError>;

View File

@ -1,25 +1,23 @@
use crate::app::{App, action::Action}; use crate::app::{App, action::Action};
use color_eyre::Result; use crate::error::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use thiserror::Error; use thiserror::Error;
use tracing::{debug, info}; use tracing::{debug, info};
#[tracing::instrument(name = "Handling input", skip(app))] #[tracing::instrument(name = "Handling input", skip(app))]
async fn handle_input(key_event: KeyEvent, app: &mut App<'_>) -> Result<Option<Action>> { async fn handle_input(key_event: KeyEvent, app: &mut App) -> Result<Option<Action>> {
match key_event.code { match key_event.code {
KeyCode::Enter => Ok(Some(Action::Submit)), KeyCode::Enter => Ok(Some(Action::Submit)),
KeyCode::Tab => { KeyCode::Tab => {
app.complete_input().await?; app.complete_input().await?;
Ok(None) Ok(None)
} }
KeyCode::Char(c) => { KeyCode::Char(ch) => {
app.input.push(c); app.input_handler.insert_char(ch);
app.cursor_position = app.input.len();
Ok(None) Ok(None)
} }
KeyCode::Backspace => { KeyCode::Backspace => {
app.input.pop(); app.input_handler.delete_char();
app.cursor_position = app.input.len();
Ok(None) Ok(None)
} }
KeyCode::Esc => Ok(Some(Action::Cancel)), KeyCode::Esc => Ok(Some(Action::Cancel)),
@ -33,7 +31,7 @@ async fn handle_input(key_event: KeyEvent, app: &mut App<'_>) -> Result<Option<A
/// ///
/// TODO: add error types /// TODO: add error types
#[tracing::instrument(name = "Getting action", skip(app))] #[tracing::instrument(name = "Getting action", skip(app))]
pub async fn get_action(key_event: KeyEvent, app: &mut App<'_>) -> Result<Option<Action>> { pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Action>> {
if app.input_mode { if app.input_mode {
return handle_input(key_event, app).await; return handle_input(key_event, app).await;
} }
@ -74,7 +72,7 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App<'_>) -> Result<Option
/// ///
/// TODO: add error types /// TODO: add error types
#[tracing::instrument(name = "Update", skip(app))] #[tracing::instrument(name = "Update", skip(app))]
pub async fn update(app: &mut App<'_>, action: Action) -> Result<()> { pub async fn update(app: &mut App, action: Action) -> Result<()> {
info!("updating app with action: {}", action); info!("updating app with action: {}", action);
match action { match action {
Action::Quit => app.quit(), Action::Quit => app.quit(),
@ -94,7 +92,7 @@ pub async fn update(app: &mut App<'_>, action: Action) -> Result<()> {
Action::Select => app.select(), Action::Select => app.select(),
Action::Submit => app.move_torrent().await?, Action::Submit => app.move_torrent().await?,
Action::Cancel => { Action::Cancel => {
app.input.clear(); app.input_handler.clear();
app.input_mode = false; app.input_mode = false;
} }
} }
@ -118,7 +116,7 @@ pub enum ParseKeybingError {
UnknownPart(String), UnknownPart(String),
} }
fn parse_keybind(key_str: &str) -> Result<KeyEvent, ParseKeybingError> { fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybingError> {
let mut modifiers = KeyModifiers::NONE; let mut modifiers = KeyModifiers::NONE;
let mut key_code = None; let mut key_code = None;

View File

@ -1,5 +1,6 @@
pub mod app; pub mod app;
pub mod config; pub mod config;
pub mod error;
pub mod event; pub mod event;
pub mod handler; pub mod handler;
pub mod telemetry; pub mod telemetry;

View File

@ -3,11 +3,15 @@ use ratatui::{Terminal, backend::CrosstermBackend};
use std::{io, sync::Arc}; use std::{io, sync::Arc};
use tokio::{ use tokio::{
sync::Mutex, sync::Mutex,
task::JoinHandle,
time::{self, Duration}, time::{self, Duration},
}; };
use tracing::{trace, warn}; use tracing::warn;
use traxor::{ use traxor::{
app::App, app::{
App,
constants::{DEFAULT_TICK_RATE_MS, TORRENT_UPDATE_INTERVAL_SECS},
},
config::Config, config::Config,
event::{Event, EventHandler}, event::{Event, EventHandler},
handler::{get_action, update}, handler::{get_action, update},
@ -18,36 +22,19 @@ use traxor::{
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
color_eyre::install()?; color_eyre::install()?;
let config = Config::load()?; let config = Config::load()?;
setup_logger(&config)?; setup_logger(&config)?;
// Wrap App in Arc<Mutex<>> so we can share it between UI and updater
let app = Arc::new(Mutex::new(App::new(config)?)); let app = Arc::new(Mutex::new(App::new(config)?));
// Clone for updater task spawn_torrent_updater(app.clone());
let app_clone = app.clone();
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(2));
loop {
interval.tick().await;
let mut app = app_clone.lock().await;
if let Err(e) = app.torrents.update().await {
warn!("Failed to update torrents: {e}");
}
}
});
// TUI setup
let backend = CrosstermBackend::new(io::stderr()); let backend = CrosstermBackend::new(io::stderr());
let terminal = Terminal::new(backend)?; let terminal = Terminal::new(backend)?;
let events = EventHandler::new(250); // Update time in ms let events = EventHandler::new(DEFAULT_TICK_RATE_MS);
let mut tui = Tui::new(terminal, events); let mut tui = Tui::new(terminal, events);
tui.init()?; tui.init()?;
// Main loop
loop { loop {
{ {
let app_guard = app.lock().await; let app_guard = app.lock().await;
@ -62,22 +49,29 @@ async fn main() -> Result<()> {
} }
match tui.events.next()? { match tui.events.next()? {
Event::Tick => {}
Event::Key(key_event) => { Event::Key(key_event) => {
let mut app_guard = app.lock().await; let mut app_guard = app.lock().await;
if let Some(action) = get_action(key_event, &mut app_guard).await? { if let Some(action) = get_action(key_event, &mut app_guard).await? {
update(&mut app_guard, action).await?; update(&mut app_guard, action).await?;
} }
} }
Event::Mouse(mouse_event) => { Event::Mouse(_) | Event::Resize(_, _) | Event::Tick => {}
trace!(target: "app", "Event::Mouse: {:?}", mouse_event);
}
Event::Resize(x, y) => {
trace!(target: "app", "Event::Resize: ({}, {})", x, y);
}
} }
} }
tui.exit()?; tui.exit()?;
Ok(()) Ok(())
} }
fn spawn_torrent_updater(app: Arc<Mutex<App>>) -> JoinHandle<()> {
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(TORRENT_UPDATE_INTERVAL_SECS));
loop {
interval.tick().await;
let mut app = app.lock().await;
if let Err(e) = app.torrents.update().await {
warn!("Failed to update torrents: {e}");
}
}
})
}

View File

@ -5,7 +5,7 @@ use ratatui::{
}; };
use tracing::warn; use tracing::warn;
pub fn render(f: &mut Frame, app: &mut App) { pub fn render(f: &mut Frame, app: &App) {
let size = f.area(); let size = f.area();
let input_area = Rect::new(size.width / 4, size.height / 2 - 1, size.width / 2, 3); let input_area = Rect::new(size.width / 4, size.height / 2 - 1, size.width / 2, 3);
@ -13,7 +13,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
f.render_widget(Clear, input_area); f.render_widget(Clear, input_area);
f.render_widget(block, input_area); f.render_widget(block, input_area);
let input = Paragraph::new(app.input.as_str()).block(Block::default()); let input = Paragraph::new(app.input_handler.text.as_str()).block(Block::default());
f.render_widget( f.render_widget(
input, input,
input_area.inner(Margin { input_area.inner(Margin {
@ -22,14 +22,10 @@ pub fn render(f: &mut Frame, app: &mut App) {
}), }),
); );
let cursor_offset = u16::try_from(app.cursor_position) let cursor_offset = u16::try_from(app.input_handler.cursor_position).unwrap_or_else(|_| {
.map_err(|_| { warn!("cursor_position out of range, clamping");
warn!( 0
"cursor_position {} out of u16 range. Clamping to 0", });
app.cursor_position
);
})
.unwrap_or_default();
f.set_cursor_position(Position::new( f.set_cursor_position(Position::new(
input_area.x + cursor_offset + 1, input_area.x + cursor_offset + 1,