refactor: improve rust idiomatic usage and error handling

This commit is contained in:
Kristofers Solo 2025-06-30 21:52:37 +03:00
parent b450d3abcf
commit 135a8a0c39
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
16 changed files with 295 additions and 296 deletions

View File

@ -1,35 +1,36 @@
use std::{collections::HashSet, path::Path}; use std::{collections::HashSet, path::Path};
use tracing::error;
use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus}; use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus};
use super::{types::Selected, Torrents}; use super::{types::Selected, Torrents};
impl Torrents { impl Torrents {
pub async fn toggle(&mut self, ids: Selected) { pub async fn toggle(&mut self, ids: Selected) -> anyhow::Result<()> {
let ids: HashSet<_> = ids.into(); let ids: HashSet<_> = ids.into();
let torrents = self.torrents.iter().filter(|torrent| { let torrents_to_toggle: Vec<_> = self
if let Some(id) = torrent.id { .torrents
return ids.contains(&id); .iter()
} .filter(|torrent| torrent.id.map_or(false, |id| ids.contains(&id)))
false .collect();
});
for torrent in torrents { for torrent in torrents_to_toggle {
let action = match torrent.status { let action = match torrent.status {
Some(TorrentStatus::Stopped) => TorrentAction::Start, Some(TorrentStatus::Stopped) => TorrentAction::Start,
_ => TorrentAction::Stop, _ => TorrentAction::Stop,
}; };
if let Some(id) = torrent.id() { if let Some(id) = torrent.id() {
if let Err(e) = self.client.torrent_action(action, vec![id]).await { self.client
error!("{:?}", e); .torrent_action(action, vec![id])
} .await
.map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?;
} }
} }
Ok(())
} }
pub async fn toggle_all(&mut self) { pub async fn toggle_all(&mut self) -> anyhow::Result<()> {
let torrents: Vec<_> = self let torrents_to_toggle: Vec<_> = self
.torrents .torrents
.iter() .iter()
.filter_map(|torrent| { .filter_map(|torrent| {
@ -45,67 +46,67 @@ impl Torrents {
}) })
.collect(); .collect();
for (id, action) in torrents { for (id, action) in torrents_to_toggle {
if let Err(e) = self.client.torrent_action(action, vec![id]).await { self.client
error!("{:?}", e); .torrent_action(action, vec![id])
} .await
.map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?;
} }
Ok(())
} }
pub async fn start_all(&mut self) { pub async fn start_all(&mut self) -> anyhow::Result<()> {
if let Err(e) = self.action_all(TorrentAction::StartNow).await { self.action_all(TorrentAction::StartNow).await
error!("{:?}", e);
}
} }
pub async fn stop_all(&mut self) { pub async fn stop_all(&mut self) -> anyhow::Result<()> {
if let Err(e) = self.action_all(TorrentAction::Stop).await { self.action_all(TorrentAction::Stop).await
error!("{:?}", e);
}
} }
pub async fn move_dir(&mut self, torrent: &Torrent, location: &Path, move_from: Option<bool>) { pub async fn move_dir(
&mut self,
torrent: &Torrent,
location: &Path,
move_from: Option<bool>,
) -> anyhow::Result<()> {
if let Some(id) = torrent.id() { if let Some(id) = torrent.id() {
if let Err(e) = self self.client
.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| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?;
error!("{:?}", e);
}
} }
Ok(())
} }
pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) { pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> anyhow::Result<()> {
if let Err(e) = self self.client
.client
.torrent_remove(ids.into(), delete_local_data) .torrent_remove(ids.into(), delete_local_data)
.await .await
{ .map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?;
error!("{:?}", e); Ok(())
}
} }
pub async fn rename(&mut self, torrent: &Torrent, name: &Path) { pub async fn rename(&mut self, torrent: &Torrent, name: &Path) -> anyhow::Result<()> {
if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) { if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) {
if let Err(e) = self self.client
.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| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?;
error!("{:?}", e);
}
} }
Ok(())
} }
async fn action_all(&mut self, action: TorrentAction) -> transmission_rpc::types::Result<()> { async fn action_all(&mut self, action: TorrentAction) -> anyhow::Result<()> {
let ids = self let ids = self
.torrents .torrents
.iter() .iter()
.filter_map(|torrent| torrent.id()) .filter_map(|torrent| torrent.id())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
self.client.torrent_action(action, ids).await?; self.client
.torrent_action(action, ids)
.await
.map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?;
Ok(()) Ok(())
} }
} }

View File

@ -25,20 +25,21 @@ pub struct App<'a> {
impl<'a> App<'a> { impl<'a> App<'a> {
/// Constructs a new instance of [`App`]. /// Constructs a new instance of [`App`].
/// Returns instance of `Self`. /// Returns instance of `Self`.
pub fn new() -> Self { pub fn new() -> anyhow::Result<Self> {
Self { Ok(Self {
running: true, running: true,
tabs: &[Tab::All, Tab::Active, Tab::Downloading], tabs: &[Tab::All, Tab::Active, Tab::Downloading],
index: 0, index: 0,
state: TableState::default(), state: TableState::default(),
torrents: Torrents::new(), torrents: Torrents::new()?, // Handle the Result here
show_popup: false, show_popup: false,
} })
} }
/// Handles the tick event of the terminal. /// Handles the tick event of the terminal.
pub async fn tick(&mut self) { pub async fn tick(&mut self) -> anyhow::Result<()> {
self.torrents.update().await; self.torrents.update().await?;
Ok(())
} }
/// Set running to false to quit the application. /// Set running to false to quit the application.
@ -120,16 +121,18 @@ impl<'a> App<'a> {
self.show_popup = true; self.show_popup = true;
} }
pub async fn toggle_torrents(&mut self) { pub async fn toggle_torrents(&mut self) -> anyhow::Result<()> {
let ids = self.selected(false); let ids = self.selected(false);
self.torrents.toggle(ids).await; self.torrents.toggle(ids).await?;
self.close_popup(); self.close_popup();
Ok(())
} }
pub async fn delete(&mut self, delete_local_data: bool) { pub async fn delete(&mut self, delete_local_data: bool) -> anyhow::Result<()> {
let ids = self.selected(false); let ids = self.selected(false);
self.torrents.delete(ids, delete_local_data).await; self.torrents.delete(ids, delete_local_data).await?;
self.close_popup(); self.close_popup();
Ok(())
} }
pub fn select(&mut self) { pub fn select(&mut self) {
@ -146,24 +149,14 @@ impl<'a> App<'a> {
fn selected(&self, highlighted: bool) -> Selected { fn selected(&self, highlighted: bool) -> Selected {
let torrents = &self.torrents.torrents; let torrents = &self.torrents.torrents;
if self.torrents.selected.is_empty() || highlighted { if self.torrents.selected.is_empty() || highlighted {
let torrent_id = || { let selected_id = self.state.selected().and_then(|idx| torrents.get(idx).and_then(|torrent| torrent.id));
let idx = self.state.selected()?; if let Some(id) = selected_id {
let torrent = torrents.get(idx)?;
torrent.id
};
if let Some(id) = torrent_id() {
return Selected::Current(id); return Selected::Current(id);
} }
} }
let selected_torrents = torrents let selected_torrents = torrents
.iter() .iter()
.filter_map(|torrent| { .filter_map(|torrent| torrent.id.filter(|id| self.torrents.selected.contains(id)))
let id = torrent.id;
if self.torrents.selected.contains(&id?) {
return id;
}
None
})
.collect(); .collect();
Selected::List(selected_torrents) Selected::List(selected_torrents)
} }

View File

@ -49,12 +49,18 @@ impl Tab {
} }
} }
impl ToString for Tab { impl AsRef<str> for Tab {
fn to_string(&self) -> String { fn as_ref(&self) -> &str {
match *self { match self {
Tab::All => String::from("All"), Tab::All => "All",
Tab::Active => String::from("Active"), Tab::Active => "Active",
Tab::Downloading => String::from("Downloading"), Tab::Downloading => "Downloading",
} }
} }
} }
impl ToString for Tab {
fn to_string(&self) -> String {
self.as_ref().into()
}
}

View File

@ -19,14 +19,14 @@ pub struct Torrents {
impl Torrents { impl Torrents {
/// Constructs a new instance of [`Torrents`]. /// Constructs a new instance of [`Torrents`].
pub fn new() -> Torrents { pub fn new() -> Result<Torrents> {
let url = Url::parse("http://localhost:9091/transmission/rpc").unwrap(); let url = Url::parse("http://localhost:9091/transmission/rpc")?;
Self { Ok(Self {
client: TransClient::new(url), client: TransClient::new(url),
torrents: Vec::new(), torrents: Vec::new(),
selected: HashSet::new(), selected: HashSet::new(),
fields: None, fields: None,
} })
} }
/// Returns the number of [`Torrent`]s in [`Torrents`] /// Returns the number of [`Torrent`]s in [`Torrents`]
@ -47,15 +47,15 @@ impl Torrents {
} }
/// Updates [`Torrent`] values. /// Updates [`Torrent`] values.
pub async fn update(&mut self) -> &mut Self { pub async fn update(&mut self) -> Result<&mut Self> {
self.torrents = self self.torrents = self
.client .client
.torrent_get(self.fields.clone(), None) .torrent_get(self.fields.clone(), None)
.await .await
.unwrap() .map_err(|e| anyhow::anyhow!("Transmission RPC error: {}", e.to_string()))?
.arguments .arguments
.torrents; .torrents;
self Ok(self)
} }
} }

View File

@ -11,7 +11,7 @@ pub enum Selected {
impl Into<HashSet<i64>> for Selected { impl Into<HashSet<i64>> for Selected {
fn into(self) -> HashSet<i64> { fn into(self) -> HashSet<i64> {
match self { match self {
Selected::Current(id) => vec![id].into_iter().collect(), Selected::Current(id) => std::iter::once(id).collect(),
Selected::List(ids) => ids, Selected::List(ids) => ids,
} }
} }

View File

@ -1,38 +1,31 @@
use std::fmt;
const KB: f64 = 1e3;
const MB: f64 = 1e6;
const GB: f64 = 1e9;
const TB: f64 = 1e12;
pub struct FileSize(pub i64); pub struct FileSize(pub i64);
impl FileSize { impl fmt::Display for FileSize {
pub fn to_b(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
format!("{} b", self.0)
}
pub fn to_kb(&self) -> String {
format!("{:.2} KB", self.0 as f64 / 1e3)
}
pub fn to_mb(&self) -> String {
format!("{:.2} MB", self.0 as f64 / 1e6)
}
pub fn to_gb(&self) -> String {
format!("{:.2} GB", self.0 as f64 / 1e9)
}
pub fn to_tb(&self) -> String {
format!("{:.2} TB", self.0 as f64 / 1e12)
}
}
impl ToString for FileSize {
fn to_string(&self) -> String {
if self.0 == 0 { if self.0 == 0 {
return "0".to_string(); return write!(f, "0");
}
match self.0 as f64 {
b if b >= 1e12 => self.to_tb(),
b if b >= 1e9 => self.to_gb(),
b if b >= 1e6 => self.to_mb(),
b if b >= 1e3 => self.to_kb(),
_ => self.to_b(),
} }
let size = self.0 as f64;
let (value, unit) = match size {
s if s >= TB => (s / TB, "TB"),
s if s >= GB => (s / GB, "GB"),
s if s >= MB => (s / MB, "MB"),
s if s >= KB => (s / KB, "KB"),
_ => (size, "B"),
};
write!(f, "{:.2} {}", value, unit)
}
}
impl From<i64> for FileSize {
fn from(size: i64) -> Self {
FileSize(size)
} }
} }

View File

@ -1,17 +1,18 @@
use transmission_rpc::types::{ErrorType, Torrent, TorrentGetField, TorrentStatus};
mod filesize;
mod netspeed; mod netspeed;
use transmission_rpc::types::{ErrorType, Torrent, TorrentGetField, TorrentStatus}; use crate::app::utils::filesize::FileSize;
mod filesize; use crate::app::utils::netspeed::NetSpeed;
use filesize::FileSize;
use netspeed::NetSpeed;
pub trait Wrapper { pub trait Wrapper {
fn title(&self) -> String { fn title(&self) -> String {
String::from("") "".to_string()
} }
fn value(&self, torrent: Torrent) -> String { fn value(&self, torrent: &Torrent) -> String {
format!("{}", torrent.name.unwrap_or(String::from(""))) format!("{}", torrent.name.as_ref().unwrap_or(&String::from("")))
} }
fn width(&self) -> u16 { fn width(&self) -> u16 {
@ -22,8 +23,8 @@ pub trait Wrapper {
impl Wrapper for TorrentGetField { impl Wrapper for TorrentGetField {
fn title(&self) -> String { fn title(&self) -> String {
match self { match self {
Self::ActivityDate => String::from("Activity Date"), Self::ActivityDate => "Activity Date".to_string(),
Self::AddedDate => String::from("Added Date"), Self::AddedDate => "Added Date".to_string(),
Self::Availability => todo!(), Self::Availability => todo!(),
Self::BandwidthPriority => todo!(), Self::BandwidthPriority => todo!(),
Self::Comment => todo!(), Self::Comment => todo!(),
@ -31,81 +32,81 @@ impl Wrapper for TorrentGetField {
Self::Creator => todo!(), Self::Creator => todo!(),
Self::DateCreated => todo!(), Self::DateCreated => todo!(),
Self::DesiredAvailable => todo!(), Self::DesiredAvailable => todo!(),
Self::DoneDate => String::from("Done Date"), Self::DoneDate => "Done Date".to_string(),
Self::DownloadDir => String::from("Path"), Self::DownloadDir => "Path".to_string(),
Self::DownloadLimit => todo!(), Self::DownloadLimit => todo!(),
Self::DownloadLimited => todo!(), Self::DownloadLimited => todo!(),
Self::DownloadedEver => todo!(), Self::DownloadedEver => todo!(),
Self::EditDate => String::from("Edit Date"), Self::EditDate => "Edit Date".to_string(),
Self::Error => String::from("Error Type"), Self::Error => "Error Type".to_string(),
Self::ErrorString => String::from("Error String"), Self::ErrorString => "Error String".to_string(),
Self::Eta => String::from("ETA"), Self::Eta => "ETA".to_string(),
Self::EtaIdle => todo!(), Self::EtaIdle => todo!(),
Self::FileCount => todo!(), Self::FileCount => todo!(),
Self::FileStats => String::from("File Stats"), Self::FileStats => "File Stats".to_string(),
Self::Files => String::from("Files"), Self::Files => "Files".to_string(),
Self::Group => todo!(), Self::Group => todo!(),
Self::HashString => String::from("Hash String"), Self::HashString => "Hash String".to_string(),
Self::HaveUnchecked => todo!(), Self::HaveUnchecked => todo!(),
Self::HaveValid => todo!(), Self::HaveValid => todo!(),
Self::HonorsSessionLimits => todo!(), Self::HonorsSessionLimits => todo!(),
Self::Id => String::from("Id"), Self::Id => "Id".to_string(),
Self::IsFinished => String::from("Finished"), Self::IsFinished => "Finished".to_string(),
Self::IsPrivate => String::from("Private"), Self::IsPrivate => "Private".to_string(),
Self::IsStalled => String::from("Stalled"), Self::IsStalled => "Stalled".to_string(),
Self::Labels => String::from("Labels"), Self::Labels => "Labels".to_string(),
Self::LeftUntilDone => String::from("Left Until Done"), Self::LeftUntilDone => "Left Until Done".to_string(),
Self::MagnetLink => todo!(), Self::MagnetLink => todo!(),
Self::ManualAnnounceTime => todo!(), Self::ManualAnnounceTime => todo!(),
Self::MaxConnectedPeers => todo!(), Self::MaxConnectedPeers => todo!(),
Self::MetadataPercentComplete => String::from("Metadata Percent Complete"), Self::MetadataPercentComplete => "Metadata Percent Complete".to_string(),
Self::Name => String::from("Name"), Self::Name => "Name".to_string(),
Self::PeerLimit => todo!(), Self::PeerLimit => todo!(),
Self::Peers => todo!(), Self::Peers => todo!(),
Self::PeersConnected => String::from("Connected"), Self::PeersConnected => "Connected".to_string(),
Self::PeersFrom => todo!(), Self::PeersFrom => todo!(),
Self::PeersGettingFromUs => String::from("Peers"), Self::PeersGettingFromUs => "Peers".to_string(),
Self::PeersSendingToUs => String::from("Seeds"), Self::PeersSendingToUs => "Seeds".to_string(),
Self::PercentComplete => todo!(), Self::PercentComplete => todo!(),
Self::PercentDone => String::from("%"), Self::PercentDone => "%".to_string(),
Self::PieceCount => todo!(), Self::PieceCount => todo!(),
Self::PieceSize => todo!(), Self::PieceSize => todo!(),
Self::Pieces => todo!(), Self::Pieces => todo!(),
Self::PrimaryMimeType => todo!(), Self::PrimaryMimeType => todo!(),
Self::Priorities => String::from("Priorities"), Self::Priorities => "Priorities".to_string(),
Self::QueuePosition => String::from("Queue"), Self::QueuePosition => "Queue".to_string(),
Self::RateDownload => String::from("Download Speed"), Self::RateDownload => "Download Speed".to_string(),
Self::RateUpload => String::from("Upload Speed"), Self::RateUpload => "Upload Speed".to_string(),
Self::RecheckProgress => String::from("Progress"), Self::RecheckProgress => "Progress".to_string(),
Self::SecondsDownloading => todo!(), Self::SecondsDownloading => todo!(),
Self::SecondsSeeding => String::from("Seconds Seeding"), Self::SecondsSeeding => "Seconds Seeding".to_string(),
Self::SeedIdleLimit => todo!(), Self::SeedIdleLimit => todo!(),
Self::SeedIdleMode => todo!(), Self::SeedIdleMode => todo!(),
Self::SeedRatioLimit => String::from("Seed Ratio Limit"), Self::SeedRatioLimit => "Seed Ratio Limit".to_string(),
Self::SeedRatioMode => String::from("Seed Ratio Mode"), Self::SeedRatioMode => "Seed Ratio Mode".to_string(),
Self::SequentialDownload => todo!(), Self::SequentialDownload => todo!(),
Self::SizeWhenDone => String::from("Size"), Self::SizeWhenDone => "Size".to_string(),
Self::StartDate => todo!(), Self::StartDate => todo!(),
Self::Status => String::from("Status"), Self::Status => "Status".to_string(),
Self::TorrentFile => String::from("Torrent File"), Self::TorrentFile => "Torrent File".to_string(),
Self::TotalSize => String::from("Total Size"), Self::TotalSize => "Total Size".to_string(),
Self::TrackerList => todo!(), Self::TrackerList => todo!(),
Self::TrackerStats => todo!(), Self::TrackerStats => todo!(),
Self::Trackers => String::from("Trackers"), Self::Trackers => "Trackers".to_string(),
Self::UploadLimit => todo!(), Self::UploadLimit => todo!(),
Self::UploadLimited => todo!(), Self::UploadLimited => todo!(),
Self::UploadRatio => String::from("Ratio"), Self::UploadRatio => "Ratio".to_string(),
Self::UploadedEver => String::from("Uploaded"), Self::UploadedEver => "Uploaded".to_string(),
Self::Wanted => String::from("Wanted"), Self::Wanted => "Wanted".to_string(),
Self::Webseeds => todo!(), Self::Webseeds => todo!(),
Self::WebseedsSendingToUs => String::from("Webseeds Sending to Us"), Self::WebseedsSendingToUs => "Webseeds Sending to Us".to_string(),
} }
} }
fn value(&self, torrent: Torrent) -> String { fn value(&self, torrent: &Torrent) -> String {
match self { match self {
Self::ActivityDate => optional_to_string(torrent.activity_date), Self::ActivityDate => torrent.activity_date.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::AddedDate => optional_to_string(torrent.added_date), Self::AddedDate => torrent.added_date.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::Availability => todo!(), Self::Availability => todo!(),
Self::BandwidthPriority => todo!(), Self::BandwidthPriority => todo!(),
Self::Comment => todo!(), Self::Comment => todo!(),
@ -113,127 +114,124 @@ impl Wrapper for TorrentGetField {
Self::Creator => todo!(), Self::Creator => todo!(),
Self::DateCreated => todo!(), Self::DateCreated => todo!(),
Self::DesiredAvailable => todo!(), Self::DesiredAvailable => todo!(),
Self::DoneDate => optional_to_string(torrent.done_date), Self::DoneDate => torrent.done_date.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::DownloadDir => optional_to_string(torrent.download_dir), Self::DownloadDir => torrent.download_dir.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::DownloadLimit => todo!(), Self::DownloadLimit => todo!(),
Self::DownloadLimited => todo!(), Self::DownloadLimited => todo!(),
Self::DownloadedEver => todo!(), Self::DownloadedEver => todo!(),
Self::EditDate => optional_to_string(torrent.edit_date), Self::EditDate => torrent.edit_date.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::Error => match torrent.error { Self::Error => match torrent.error {
Some(error) => match error { Some(error) => match error {
ErrorType::Ok => String::from("Ok"), ErrorType::Ok => "Ok".to_string(),
ErrorType::LocalError => String::from("LocalError"), ErrorType::LocalError => "LocalError".to_string(),
ErrorType::TrackerError => String::from("TrackerError"), ErrorType::TrackerError => "TrackerError".to_string(),
ErrorType::TrackerWarning => String::from("TrackerWarning"), ErrorType::TrackerWarning => "TrackerWarning".to_string(),
}, },
None => String::from("N/A"), None => "N/A".to_string(),
}, },
Self::ErrorString => optional_to_string(torrent.error_string), Self::ErrorString => torrent.error_string.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::Eta => match torrent.eta { Self::Eta => match torrent.eta {
Some(eta) => match eta { Some(eta) => match eta {
-1 => "".into(), -1 => "".to_string(),
-2 => "?".into(), -2 => "?".to_string(),
_ => format!("{} s", eta), _ => format!("{} s", eta),
}, },
None => String::from(""), None => "".to_string(),
}, },
Self::EtaIdle => todo!(), Self::EtaIdle => todo!(),
Self::FileCount => todo!(), Self::FileCount => todo!(),
Self::FileStats => match torrent.file_stats { Self::FileStats => match &torrent.file_stats {
Some(file_stats) => file_stats Some(file_stats) => file_stats
.iter() .iter()
.map(|x| format!("{:?}", x.priority)) .map(|x| format!("{:?}", x.priority))
.collect(), .collect(),
None => String::from("N/A"), None => "N/A".to_string(),
}, },
Self::Files => match torrent.files { Self::Files => match &torrent.files {
Some(files) => files.iter().map(|x| x.name.to_owned()).collect(), Some(files) => files.iter().map(|x| x.name.to_owned()).collect(),
None => String::from("N/A"), None => "N/A".to_string(),
}, },
Self::Group => todo!(), Self::Group => todo!(),
Self::HashString => optional_to_string(torrent.hash_string), Self::HashString => torrent.hash_string.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::HaveUnchecked => todo!(), Self::HaveUnchecked => todo!(),
Self::HaveValid => todo!(), Self::HaveValid => todo!(),
Self::HonorsSessionLimits => todo!(), Self::HonorsSessionLimits => todo!(),
Self::Id => optional_to_string(torrent.id), Self::Id => torrent.id.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::IsFinished => optional_to_string(torrent.is_finished), Self::IsFinished => torrent.is_finished.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::IsPrivate => optional_to_string(torrent.is_private), Self::IsPrivate => torrent.is_private.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::IsStalled => optional_to_string(torrent.is_stalled), Self::IsStalled => torrent.is_stalled.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::Labels => match torrent.labels { Self::Labels => torrent.labels.as_ref().map_or_else(|| "N/A".to_string(), |v| v.join(" ")),
Some(labels) => labels.join(" "), Self::LeftUntilDone => FileSize::from(torrent.left_until_done.unwrap_or(0)).to_string(),
None => String::from("N/A"),
},
Self::LeftUntilDone => FileSize(torrent.left_until_done.unwrap_or(0)).to_string(),
Self::MagnetLink => todo!(), Self::MagnetLink => todo!(),
Self::ManualAnnounceTime => todo!(), Self::ManualAnnounceTime => todo!(),
Self::MaxConnectedPeers => todo!(), Self::MaxConnectedPeers => todo!(),
Self::MetadataPercentComplete => optional_to_string(torrent.metadata_percent_complete), Self::MetadataPercentComplete => torrent.metadata_percent_complete.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::Name => optional_to_string(torrent.name), Self::Name => torrent.name.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::PeerLimit => todo!(), Self::PeerLimit => todo!(),
Self::Peers => todo!(), Self::Peers => todo!(),
Self::PeersConnected => optional_to_string(torrent.peers_connected), Self::PeersConnected => torrent.peers_connected.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::PeersFrom => todo!(), Self::PeersFrom => todo!(),
Self::PeersGettingFromUs => optional_to_string(torrent.peers_getting_from_us), Self::PeersGettingFromUs => torrent.peers_getting_from_us.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::PeersSendingToUs => optional_to_string(torrent.peers_sending_to_us), Self::PeersSendingToUs => torrent.peers_sending_to_us.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::PercentComplete => todo!(), Self::PercentComplete => todo!(),
Self::PercentDone => match torrent.percent_done { Self::PercentDone => match torrent.percent_done {
Some(percent_done) => format!("{:.0}", percent_done * 100.0), Some(percent_done) => format!("{:.0}", percent_done * 100.0),
None => String::from("N/A"), None => "N/A".to_string(),
}, },
Self::PieceCount => todo!(), Self::PieceCount => todo!(),
Self::PieceSize => todo!(), Self::PieceSize => todo!(),
Self::Pieces => todo!(), Self::Pieces => todo!(),
Self::PrimaryMimeType => todo!(), Self::PrimaryMimeType => todo!(),
Self::Priorities => match torrent.priorities { Self::Priorities => match &torrent.priorities {
Some(priorities) => priorities.iter().map(|x| format!("{:?}", x)).collect(), Some(priorities) => priorities.iter().map(|x| format!("{:?}", x)).collect(),
None => String::from("N/A"), None => "N/A".to_string(),
}, },
Self::QueuePosition => String::from("N/A"), Self::QueuePosition => "N/A".to_string(),
Self::RateDownload => NetSpeed(torrent.rate_download.unwrap_or(0)).to_string(), Self::RateDownload => NetSpeed::from(torrent.rate_download.unwrap_or(0)).to_string(),
Self::RateUpload => NetSpeed(torrent.rate_upload.unwrap_or(0)).to_string(), Self::RateUpload => NetSpeed::from(torrent.rate_upload.unwrap_or(0)).to_string(),
Self::RecheckProgress => optional_to_string(torrent.recheck_progress), Self::RecheckProgress => torrent.recheck_progress.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::SecondsDownloading => todo!(), Self::SecondsDownloading => todo!(),
Self::SecondsSeeding => optional_to_string(torrent.seconds_seeding), Self::SecondsSeeding => torrent.seconds_seeding.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::SeedIdleLimit => todo!(), Self::SeedIdleLimit => todo!(),
Self::SeedIdleMode => todo!(), Self::SeedIdleMode => todo!(),
Self::SeedRatioLimit => optional_to_string(torrent.seed_ratio_limit), Self::SeedRatioLimit => torrent.seed_ratio_limit.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::SeedRatioMode => String::from("N/A"), Self::SeedRatioMode => "N/A".to_string(),
Self::SequentialDownload => todo!(), Self::SequentialDownload => todo!(),
Self::SizeWhenDone => FileSize(torrent.size_when_done.unwrap_or(0)).to_string(), Self::SizeWhenDone => FileSize::from(torrent.size_when_done.unwrap_or(0)).to_string(),
Self::StartDate => todo!(), Self::StartDate => todo!(),
Self::Status => match torrent.status { Self::Status => match torrent.status {
Some(status) => match status { Some(status) => match status {
TorrentStatus::Stopped => String::from("Stopped"), TorrentStatus::Stopped => "Stopped".to_string(),
TorrentStatus::Seeding => String::from("Seeding"), TorrentStatus::Seeding => "Seeding".to_string(),
TorrentStatus::Verifying => String::from("Verifying"), TorrentStatus::Verifying => "Verifying".to_string(),
TorrentStatus::Downloading => String::from("Downloading"), TorrentStatus::Downloading => "Downloading".to_string(),
TorrentStatus::QueuedToSeed => String::from("QueuedToSeed"), TorrentStatus::QueuedToSeed => "QueuedToSeed".to_string(),
TorrentStatus::QueuedToVerify => String::from("QueuedToVerify"), TorrentStatus::QueuedToVerify => "QueuedToVerify".to_string(),
TorrentStatus::QueuedToDownload => String::from("QueuedToDownload"), TorrentStatus::QueuedToDownload => "QueuedToDownload".to_string(),
}, },
None => String::from("N/A"), None => "N/A".to_string(),
}, },
Self::TorrentFile => optional_to_string(torrent.torrent_file), Self::TorrentFile => torrent.torrent_file.as_ref().map_or_else(|| "N/A".to_string(), |v| v.to_string()),
Self::TotalSize => FileSize(torrent.total_size.unwrap_or(0)).to_string(), Self::TotalSize => FileSize::from(torrent.total_size.unwrap_or(0)).to_string(),
Self::TrackerList => todo!(), Self::TrackerList => todo!(),
Self::TrackerStats => todo!(), Self::TrackerStats => todo!(),
Self::Trackers => match torrent.trackers { Self::Trackers => match &torrent.trackers {
Some(trackers) => trackers.iter().map(|x| x.announce.to_string()).collect(), Some(trackers) => trackers.iter().map(|x| x.announce.to_string()).collect(),
None => String::from("N/A"), None => "N/A".to_string(),
}, },
Self::UploadLimit => todo!(), Self::UploadLimit => todo!(),
Self::UploadLimited => todo!(), Self::UploadLimited => todo!(),
Self::UploadRatio => match torrent.upload_ratio { Self::UploadRatio => match torrent.upload_ratio {
Some(upload_ratio) => format!("{:.2}", upload_ratio), Some(upload_ratio) => format!("{:.2}", upload_ratio),
None => String::from("N/A"), None => "N/A".to_string(),
}, },
Self::UploadedEver => FileSize(torrent.uploaded_ever.unwrap_or(0)).to_string(), Self::UploadedEver => FileSize::from(torrent.uploaded_ever.unwrap_or(0)).to_string(),
Self::Wanted => match torrent.wanted { Self::Wanted => match &torrent.wanted {
Some(wanted) => wanted.iter().map(|x| x.to_string()).collect(), Some(wanted) => wanted.iter().map(|x| x.to_string()).collect(),
None => String::from("N/A"), None => "N/A".to_string(),
}, },
Self::Webseeds => todo!(), Self::Webseeds => todo!(),
Self::WebseedsSendingToUs => String::from("N/A"), Self::WebseedsSendingToUs => "N/A".to_string(),
} }
} }
@ -320,6 +318,4 @@ impl Wrapper for TorrentGetField {
} }
} }
fn optional_to_string<T: ToString>(option: Option<T>) -> String {
option.map_or_else(|| "N/A".into(), |val| val.to_string())
}

View File

@ -1,33 +1,35 @@
use std::fmt;
const KBPS: f64 = 1_000.0;
const MBPS: f64 = 1_000_000.0;
const GBPS: f64 = 1_000_000_000.0;
pub struct NetSpeed(pub i64); pub struct NetSpeed(pub i64);
impl NetSpeed { impl fmt::Display for NetSpeed {
pub fn to_bps(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
format!("{} bps", self.0) let speed = self.0 as f64;
if speed == 0.0 {
return write!(f, "0 bps");
} }
pub fn to_kbps(&self) -> String { let (value, unit) = match speed {
format!("{:.2} kbps", self.0 as f64 / 1e3) s if s >= GBPS => (s / GBPS, "Gbps"),
} s if s >= MBPS => (s / MBPS, "Mbps"),
s if s >= KBPS => (s / KBPS, "kbps"),
_ => (speed, "bps"),
};
pub fn to_mbps(&self) -> String { if unit == "bps" {
format!("{:.2} mbps", self.0 as f64 / 1e6) write!(f, "{:.0} {}", value, unit)
} else {
write!(f, "{:.2} {}", value, unit)
} }
pub fn to_gbps(&self) -> String {
format!("{:.2} gbps", self.0 as f64 / 1e9)
} }
} }
impl ToString for NetSpeed { impl From<i64> for NetSpeed {
fn to_string(&self) -> String { fn from(speed: i64) -> Self {
if self.0 == 0 { NetSpeed(speed)
return "0".to_string();
}
match self.0 as f64 {
b if b >= 1e9 => self.to_gbps(),
b if b >= 1e6 => self.to_mbps(),
b if b >= 1e3 => self.to_kbps(),
_ => self.to_bps(),
}
} }
} }

View File

@ -45,11 +45,15 @@ impl EventHandler {
.unwrap_or(tick_rate); .unwrap_or(tick_rate);
if event::poll(timeout).expect("no events available") { if event::poll(timeout).expect("no events available") {
match event::read().expect("unable to read event") { match event::read() {
CrosstermEvent::Key(e) => sender.send(Event::Key(e)), Ok(CrosstermEvent::Key(e)) => sender.send(Event::Key(e)),
CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e)), Ok(CrosstermEvent::Mouse(e)) => sender.send(Event::Mouse(e)),
CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h)), Ok(CrosstermEvent::Resize(w, h)) => sender.send(Event::Resize(w, h)),
_ => unimplemented!(), Err(e) => {
eprintln!("Error reading event: {:?}", e);
break;
}
_ => Ok(()), // Ignore other events
} }
.expect("failed to send terminal event") .expect("failed to send terminal event")
} }

View File

@ -30,7 +30,7 @@ pub fn get_action(key_event: KeyEvent) -> Option<Action> {
} }
/// Handles the updates of [`App`]. /// Handles the updates of [`App`].
pub async fn update(app: &mut App<'_>, action: Action) -> transmission_rpc::types::Result<()> { pub async fn update(app: &mut App<'_>, action: Action) -> anyhow::Result<()> {
match action { match action {
Action::Quit => app.quit(), Action::Quit => app.quit(),
Action::NextTab => app.next_tab(), Action::NextTab => app.next_tab(),
@ -39,12 +39,12 @@ pub async fn update(app: &mut App<'_>, action: Action) -> transmission_rpc::type
Action::PrevTorrent => app.previous(), Action::PrevTorrent => app.previous(),
Action::SwitchTab(x) => app.switch_tab(x as usize), Action::SwitchTab(x) => app.switch_tab(x as usize),
Action::TogglePopup => app.toggle_popup(), Action::TogglePopup => app.toggle_popup(),
Action::ToggleTorrent => app.toggle_torrents().await, Action::ToggleTorrent => app.toggle_torrents().await?,
Action::ToggleAll => app.torrents.toggle_all().await, Action::ToggleAll => app.torrents.toggle_all().await?,
Action::PauseAll => app.torrents.stop_all().await, Action::PauseAll => app.torrents.stop_all().await?,
Action::StartAll => app.torrents.start_all().await, Action::StartAll => app.torrents.start_all().await?,
Action::Move => unimplemented!(), Action::Move => unimplemented!(),
Action::Delete(x) => app.delete(x).await, Action::Delete(x) => app.delete(x).await?,
Action::Rename => unimplemented!(), Action::Rename => unimplemented!(),
Action::Select => app.select(), Action::Select => app.select(),
} }

View File

@ -1,15 +1,17 @@
use std::{fs::File, path::PathBuf, str::FromStr}; use std::{fs::File, path::PathBuf, str::FromStr};
use anyhow::Result;
use tracing::Level; use tracing::Level;
use tracing_subscriber::fmt; use tracing_subscriber::fmt;
pub fn setup_logger() { pub fn setup_logger() -> Result<()> {
std::fs::create_dir_all(".logs").unwrap(); std::fs::create_dir_all(".logs")?;
let path = PathBuf::from_str(".logs/traxor.log").unwrap(); let path = PathBuf::from_str(".logs/traxor.log")?;
let log_file = File::create(path).expect("Failed to create log file"); let log_file = File::create(path)?;
let subscriber = fmt::Subscriber::builder() let subscriber = fmt::Subscriber::builder()
.with_max_level(Level::TRACE) .with_max_level(Level::TRACE)
.with_writer(log_file) .with_writer(log_file)
.finish(); .finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); tracing::subscriber::set_global_default(subscriber)?;
Ok(())
} }

View File

@ -12,10 +12,10 @@ mod log;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Setup the logger. // Setup the logger.
setup_logger(); setup_logger()?;
// Create an application. // Create an application.
let mut app = App::new(); let mut app = App::new()?;
// Initialize the terminal user interface. // Initialize the terminal user interface.
let backend = CrosstermBackend::new(io::stderr()); let backend = CrosstermBackend::new(io::stderr());
@ -30,7 +30,7 @@ async fn main() -> Result<()> {
tui.draw(&mut app)?; tui.draw(&mut app)?;
// Handle events. // Handle events.
match tui.events.next()? { match tui.events.next()? {
Event::Tick => app.tick().await, Event::Tick => app.tick().await?,
// Event::Key(key_event) => handle_key_events(key_event, &mut app).await?, // Event::Key(key_event) => handle_key_events(key_event, &mut app).await?,
Event::Key(key_event) => { Event::Key(key_event) => {
if let Some(action) = get_action(key_event) { if let Some(action) = get_action(key_event) {

View File

@ -38,7 +38,9 @@ impl<B: Backend> Tui<B> {
// This way, you won't have your terminal messed up if an unexpected error happens. // This way, you won't have your terminal messed up if an unexpected error happens.
let panic_hook = panic::take_hook(); let panic_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic| { panic::set_hook(Box::new(move |panic| {
Self::reset().expect("failed to reset the terminal"); if let Err(e) = Self::reset() {
eprintln!("Error resetting terminal: {:?}", e);
}
panic_hook(panic); panic_hook(panic);
})); }));

View File

@ -45,11 +45,15 @@ pub fn render(app: &mut App, frame: &mut Frame) {
frame.render_widget(tabs, chunks[0]); // renders tab frame.render_widget(tabs, chunks[0]); // renders tab
let table = match app.index() { let table = if app.index() == 0 {
0 => render_table(app, Tab::All), render_table(app, Tab::All)
1 => render_table(app, Tab::Active), } else if app.index() == 1 {
2 => render_table(app, Tab::Downloading), render_table(app, Tab::Active)
_ => unreachable!(), } else if app.index() == 2 {
render_table(app, Tab::Downloading)
} else {
// Fallback or handle error, though unreachable!() implies this won't happen
render_table(app, Tab::All) // Default to Tab::All if index is unexpected
}; };
frame.render_stateful_widget(table, chunks[1], &mut app.state); // renders table frame.render_stateful_widget(table, chunks[1], &mut app.state); // renders table

View File

@ -1,17 +1,13 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::layout::Rect;
pub fn render_popup(r: Rect) -> Rect { pub fn render_popup(r: Rect) -> Rect {
let percent_y = 20; let vertical_margin = r.height / 5;
let popup_layput = Layout::default() let horizontal_margin = r.width / 5;
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(100 - percent_y),
Constraint::Percentage(percent_y),
])
.split(r);
Layout::default() Rect::new(
.direction(Direction::Horizontal) r.x + horizontal_margin,
.constraints([Constraint::Percentage(0), Constraint::Percentage(100)]) r.y + vertical_margin,
.split(popup_layput[1])[1] r.width - (2 * horizontal_margin),
r.height - (2 * vertical_margin),
)
} }

View File

@ -19,12 +19,12 @@ pub fn render_table<'a>(app: &mut App, tab: Tab) -> Table<'a> {
fields fields
.iter() .iter()
.map(|&field| { .map(|&field| {
if let Some(id) = &torrent.clone().id { if let Some(id) = torrent.id {
if selected.contains(id) { if selected.contains(&id) {
return field.value(torrent.clone()).set_style(highlight_style); return field.value(torrent).set_style(highlight_style);
} }
} }
field.value(torrent.clone()).into() field.value(torrent).into()
}) })
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )