refactor: improve idiomatic Rust patterns and optimize RPC calls

This commit is contained in:
Kristofers Solo 2026-01-01 03:52:03 +02:00
parent be542551f3
commit d352c95221
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
9 changed files with 303 additions and 294 deletions

4
Cargo.lock generated
View File

@ -89,9 +89,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.9.1" version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"

View File

@ -1,92 +1,105 @@
use super::{Torrents, types::Selected}; use super::{Torrents, types::Selected};
use crate::error::Result; 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::{Id, Torrent, TorrentAction, TorrentStatus};
impl Torrents { impl Torrents {
/// Toggle selected torrents between started and stopped states.
///
/// # Errors /// # Errors
/// ///
/// TODO: add error types /// Returns an error if the RPC call fails.
pub async fn toggle(&mut self, ids: Selected) -> Result<()> { pub async fn toggle(&mut self, ids: Selected) -> Result<()> {
let ids: HashSet<_> = ids.into(); let selected: HashSet<_> = ids.into();
let torrents_to_toggle = self
let (to_start, to_stop): (Vec<_>, Vec<_>) = self
.torrents .torrents
.iter() .iter()
.filter(|torrent| torrent.id.is_some_and(|id| ids.contains(&id))) .filter_map(|t| {
.collect::<Vec<_>>(); t.id.filter(|id| selected.contains(id))
.map(|id| (Id::Id(id), t.status))
for torrent in torrents_to_toggle {
let action = match torrent.status {
Some(TorrentStatus::Stopped) => TorrentAction::Start,
_ => TorrentAction::Stop,
};
if let Some(id) = torrent.id() {
self.client.torrent_action(action, vec![id]).await?;
}
}
Ok(())
}
/// # Errors
///
/// TODO: add error types
pub async fn toggle_all(&mut self) -> Result<()> {
let torrents_to_toggle: Vec<_> = self
.torrents
.iter()
.filter_map(|torrent| {
torrent.id().map(|id| {
(
id,
match torrent.status {
Some(TorrentStatus::Stopped) => TorrentAction::StartNow,
_ => TorrentAction::Stop,
},
)
})
}) })
.collect(); .partition(|(_, status)| matches!(status, Some(TorrentStatus::Stopped)));
for (id, action) in torrents_to_toggle { if !to_start.is_empty() {
self.client.torrent_action(action, vec![id]).await?; let ids: Vec<_> = to_start.into_iter().map(|(id, _)| id).collect();
self.client.torrent_action(TorrentAction::Start, ids).await?;
}
if !to_stop.is_empty() {
let ids: Vec<_> = to_stop.into_iter().map(|(id, _)| id).collect();
self.client.torrent_action(TorrentAction::Stop, ids).await?;
} }
Ok(()) Ok(())
} }
/// Toggle all torrents between started and stopped states.
///
/// # Errors /// # Errors
/// ///
/// TODO: add error types /// Returns an error if the RPC call fails.
pub async fn toggle_all(&mut self) -> Result<()> {
let (to_start, to_stop): (Vec<_>, Vec<_>) = self
.torrents
.iter()
.filter_map(|t| t.id().map(|id| (id, t.status)))
.partition(|(_, status)| matches!(status, Some(TorrentStatus::Stopped)));
if !to_start.is_empty() {
let ids: Vec<_> = to_start.into_iter().map(|(id, _)| id).collect();
self.client
.torrent_action(TorrentAction::StartNow, ids)
.await?;
}
if !to_stop.is_empty() {
let ids: Vec<_> = to_stop.into_iter().map(|(id, _)| id).collect();
self.client.torrent_action(TorrentAction::Stop, ids).await?;
}
Ok(())
}
/// Start all torrents immediately.
///
/// # Errors
///
/// Returns an error if the RPC call fails.
pub async fn start_all(&mut self) -> Result<()> { pub async fn start_all(&mut self) -> Result<()> {
self.action_all(TorrentAction::StartNow).await self.action_all(TorrentAction::StartNow).await
} }
/// Stop all torrents.
///
/// # Errors /// # Errors
/// ///
/// TODO: add error types /// Returns an error if the RPC call fails.
pub async fn stop_all(&mut self) -> Result<()> { pub async fn stop_all(&mut self) -> Result<()> {
self.action_all(TorrentAction::Stop).await self.action_all(TorrentAction::Stop).await
} }
/// Move a torrent to a new location.
///
/// # Errors /// # Errors
/// ///
/// TODO: add error types /// Returns an error if the RPC call fails.
pub async fn move_dir( pub async fn move_dir(
&mut self, &mut self,
torrent: &Torrent, torrent: &Torrent,
location: &Path, location: &Path,
move_from: Option<bool>, move_from: Option<bool>,
) -> Result<()> { ) -> Result<()> {
if let Some(id) = torrent.id() { let Some(id) = torrent.id() else {
self.client return Ok(());
.torrent_set_location(vec![id], location.to_string_lossy().into(), move_from) };
.await?; self.client
} .torrent_set_location(vec![id], location.to_string_lossy().into_owned(), move_from)
.await?;
Ok(()) Ok(())
} }
/// Delete torrents, optionally removing local data.
///
/// # Errors /// # Errors
/// ///
/// TODO: add error types /// Returns an error if the RPC call fails.
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)
@ -94,29 +107,26 @@ impl Torrents {
Ok(()) Ok(())
} }
/// Rename a torrent.
///
/// # Errors /// # Errors
/// ///
/// TODO: add error types /// Returns an error if the RPC call fails.
pub async fn rename(&mut self, torrent: &Torrent, name: &Path) -> Result<()> { pub async fn rename(&mut self, torrent: &Torrent, name: &Path) -> Result<()> {
if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) { let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.as_ref()) else {
self.client return Ok(());
.torrent_rename_path(vec![id], old_name, name.to_string_lossy().into()) };
.await?; self.client
.torrent_rename_path(vec![id], old_name.clone(), name.to_string_lossy().into_owned())
.await?;
Ok(())
}
async fn action_all(&mut self, action: TorrentAction) -> Result<()> {
let ids: Vec<_> = self.torrents.iter().filter_map(Torrent::id).collect();
if !ids.is_empty() {
self.client.torrent_action(action, ids).await?;
} }
Ok(()) Ok(())
} }
/// # Errors
///
/// TODO: add error types
async fn action_all(&mut self, action: TorrentAction) -> Result<()> {
let ids = self
.torrents
.iter()
.filter_map(Torrent::id)
.collect::<Vec<_>>();
self.client.torrent_action(action, ids).await?;
Ok(())
}
} }

View File

@ -1,28 +1,37 @@
use crate::error::Result; use crate::error::Result;
use std::path::{Path, PathBuf}; use std::{
ffi::OsStr,
path::{Path, PathBuf},
};
use tokio::fs; use tokio::fs;
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct InputHandler { pub struct InputHandler {
pub text: String, pub text: String,
pub cursor_position: usize, pub cursor_position: usize,
pub completions: Vec<String>, completions: Vec<PathBuf>,
pub completion_idx: usize, completion_idx: usize,
} }
impl InputHandler { impl InputHandler {
#[inline]
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
pub fn insert_char(&mut self, ch: char) { pub fn insert_char(&mut self, ch: char) {
self.text.insert(self.cursor_position, ch); self.text.insert(self.cursor_position, ch);
self.cursor_position += 1; self.cursor_position += ch.len_utf8();
} }
pub fn delete_char(&mut self) { pub fn delete_char(&mut self) {
if self.cursor_position > 0 { if self.cursor_position > 0 {
self.cursor_position -= 1; let ch = self.text[..self.cursor_position]
.chars()
.next_back()
.expect("cursor position is valid");
self.cursor_position -= ch.len_utf8();
self.text.remove(self.cursor_position); self.text.remove(self.cursor_position);
} }
} }
@ -40,16 +49,16 @@ impl InputHandler {
} }
pub async fn complete(&mut self) -> Result<()> { pub async fn complete(&mut self) -> Result<()> {
let path = PathBuf::from(&self.text); let path = Path::new(&self.text);
let (base_path, partial_name) = split_path_components(path); let (base_path, partial_name) = split_path_components(path);
let matches = find_matching_entries(&base_path, &partial_name).await?; let matches = find_matching_entries(base_path, partial_name).await?;
self.update_completions(matches); self.update_completions(matches);
self.update_from_completions(); self.apply_completion();
Ok(()) Ok(())
} }
fn update_completions(&mut self, matches: Vec<String>) { fn update_completions(&mut self, matches: Vec<PathBuf>) {
if matches.is_empty() { if matches.is_empty() {
self.completions.clear(); self.completions.clear();
self.completion_idx = 0; self.completion_idx = 0;
@ -61,41 +70,36 @@ impl InputHandler {
} }
} }
fn update_from_completions(&mut self) { fn apply_completion(&mut self) {
if let Some(completions) = self.completions.get(self.completion_idx) { if let Some(path) = self.completions.get(self.completion_idx) {
self.set_text(completions.clone()); self.set_text(path.to_string_lossy().into_owned());
} }
} }
} }
fn split_path_components(path: PathBuf) -> (PathBuf, String) { fn split_path_components(path: &Path) -> (&Path, &OsStr) {
if path.is_dir() { if path.is_dir() {
return (path, String::new()); return (path, OsStr::new(""));
} }
let partial = path let partial = path.file_name().unwrap_or_default();
.file_name() let base = path.parent().unwrap_or_else(|| Path::new("/"));
.unwrap_or_default()
.to_string_lossy()
.to_string();
let base = path
.parent()
.unwrap_or_else(|| Path::new("/"))
.to_path_buf();
(base, partial) (base, partial)
} }
async fn find_matching_entries(base_path: &Path, partial_name: &str) -> Result<Vec<String>> { async fn find_matching_entries(base_path: &Path, partial_name: &OsStr) -> Result<Vec<PathBuf>> {
let mut entries = fs::read_dir(&base_path).await?; let partial_lower = partial_name.to_string_lossy().to_lowercase();
let mut entries = fs::read_dir(base_path).await?;
let mut matches = Vec::new(); let mut matches = Vec::new();
while let Some(entry) = entries.next_entry().await? { while let Some(entry) = entries.next_entry().await? {
let file_name = entry.file_name().to_string_lossy().to_string(); let file_name = entry.file_name();
if file_name if file_name
.to_string_lossy()
.to_lowercase() .to_lowercase()
.starts_with(&partial_name.to_lowercase()) .starts_with(&partial_lower)
{ {
matches.push(format!("{}/{}", base_path.to_string_lossy(), file_name)); matches.push(base_path.join(file_name));
} }
} }
Ok(matches) Ok(matches)

View File

@ -73,31 +73,27 @@ impl App {
} }
pub fn next(&mut self) { pub fn next(&mut self) {
let i = match self.state.selected() { let len = self.torrents.len();
Some(i) => { if len == 0 {
if i >= self.torrents.len() - 1 { return;
0 }
} else { let i = self
i + 1 .state
} .selected()
} .map_or(0, |i| if i >= len - 1 { 0 } else { i + 1 });
None => 0,
};
self.close_help(); self.close_help();
self.state.select(Some(i)); self.state.select(Some(i));
} }
pub fn previous(&mut self) { pub fn previous(&mut self) {
let i = match self.state.selected() { let len = self.torrents.len();
Some(i) => { if len == 0 {
if i == 0 { return;
self.torrents.len() - 1 }
} else { let i = self
i - 1 .state
} .selected()
} .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
None => 0,
};
self.close_help(); self.close_help();
self.state.select(Some(i)); self.state.select(Some(i));
} }
@ -111,13 +107,9 @@ impl App {
/// Switches to the previous tab. /// Switches to the previous tab.
#[inline] #[inline]
pub const fn prev_tab(&mut self) { pub fn prev_tab(&mut self) {
self.close_help(); self.close_help();
if self.index > 0 { self.index = self.index.checked_sub(1).unwrap_or(self.tabs.len() - 1);
self.index -= 1;
} else {
self.index = self.tabs.len() - 1;
}
} }
/// Switches to the tab whose index is `idx`. /// Switches to the tab whose index is `idx`.
@ -189,9 +181,9 @@ impl App {
} }
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_download_dir() {
self.input_handler self.input_handler
.set_text(download_dir.to_string_lossy().to_string()); .set_text(download_dir.to_string_lossy().into_owned());
} }
self.input_mode = true; self.input_mode = true;
} }
@ -209,32 +201,31 @@ impl App {
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 selected_id = self && let Some(id) = self
.state .state
.selected() .selected()
.and_then(|idx| torrents.get(idx).and_then(|t| t.id)); .and_then(|idx| torrents.get(idx).and_then(|t| t.id))
if let Some(id) = selected_id { {
return Selected::Current(id); return Selected::Current(id);
}
} }
let selected_torrents = torrents Selected::List(
.iter() torrents
.filter_map(|t| t.id.filter(|id| self.torrents.selected.contains(id))) .iter()
.collect(); .filter_map(|t| t.id.filter(|id| self.torrents.selected.contains(id)))
Selected::List(selected_torrents) .collect(),
)
} }
fn get_current_downlaod_dir(&self) -> Option<PathBuf> { fn get_current_download_dir(&self) -> Option<PathBuf> {
match self.selected(true) { let Selected::Current(current_id) = self.selected(true) else {
Selected::Current(current_id) => self return None;
.torrents };
.torrents self.torrents
.iter() .torrents
.find(|&t| t.id == Some(current_id)) .iter()
.and_then(|t| t.download_dir.as_ref()) .find(|t| t.id == Some(current_id))
.map(PathBuf::from), .and_then(|t| t.download_dir.as_deref())
Selected::List(_) => None, .map(PathBuf::from)
}
} }
} }

View File

@ -11,6 +11,23 @@ pub enum Selected {
List(HashSet<i64>), List(HashSet<i64>),
} }
impl Selected {
#[inline]
#[must_use]
pub fn len(&self) -> usize {
match self {
Self::Current(_) => 1,
Self::List(set) => set.len(),
}
}
#[inline]
#[must_use]
pub fn is_empty(&self) -> bool {
matches!(self, Self::List(set) if set.is_empty())
}
}
#[derive(Debug)] #[derive(Debug)]
pub enum SelectedIntoIter { pub enum SelectedIntoIter {
One(Once<i64>), One(Once<i64>),
@ -20,18 +37,30 @@ pub enum SelectedIntoIter {
impl Iterator for SelectedIntoIter { impl Iterator for SelectedIntoIter {
type Item = i64; type Item = i64;
#[inline]
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
match self { match self {
Self::One(it) => it.next(), Self::One(it) => it.next(),
Self::Many(it) => it.next(), Self::Many(it) => it.next(),
} }
} }
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
match self {
Self::One(it) => it.size_hint(),
Self::Many(it) => it.size_hint(),
}
}
} }
impl ExactSizeIterator for SelectedIntoIter {}
impl IntoIterator for Selected { impl IntoIterator for Selected {
type Item = i64; type Item = i64;
type IntoIter = SelectedIntoIter; type IntoIter = SelectedIntoIter;
#[inline]
fn into_iter(self) -> Self::IntoIter { fn into_iter(self) -> Self::IntoIter {
match self { match self {
Self::Current(id) => SelectedIntoIter::One(std::iter::once(id)), Self::Current(id) => SelectedIntoIter::One(std::iter::once(id)),
@ -54,6 +83,7 @@ impl From<Selected> for Vec<i64> {
value.into_iter().collect() value.into_iter().collect()
} }
} }
impl From<Selected> for Vec<Id> { impl From<Selected> for Vec<Id> {
fn from(value: Selected) -> Self { fn from(value: Selected) -> Self {
value.into_iter().map(Id::Id).collect() value.into_iter().map(Id::Id).collect()

View File

@ -109,83 +109,81 @@ impl Wrapper for TorrentGetField {
fn value(&self, torrent: &Torrent) -> String { fn value(&self, torrent: &Torrent) -> String {
match self { match self {
Self::ActivityDate => format_option_string(torrent.activity_date), Self::ActivityDate => format_option(torrent.activity_date),
Self::AddedDate => format_option_string(torrent.added_date), Self::AddedDate => format_option(torrent.added_date),
Self::Availability => "N/A".to_string(), Self::Availability => "N/A".into(),
Self::BandwidthPriority => torrent.bandwidth_priority.format(), Self::BandwidthPriority => torrent.bandwidth_priority.format(),
Self::Comment => torrent.comment.clone().unwrap_or_default(), Self::Comment => torrent.comment.clone().unwrap_or_default(),
Self::CorruptEver => FileSize::from(torrent.corrupt_ever).to_string(), Self::CorruptEver => FileSize::from(torrent.corrupt_ever).to_string(),
Self::Creator => torrent.creator.clone().unwrap_or_default(), Self::Creator => torrent.creator.clone().unwrap_or_default(),
Self::DateCreated => format_option_string(torrent.date_created), Self::DateCreated => format_option(torrent.date_created),
Self::DesiredAvailable => FileSize::from(torrent.desired_available).to_string(), Self::DesiredAvailable => FileSize::from(torrent.desired_available).to_string(),
Self::DoneDate => format_option_string(torrent.done_date), Self::DoneDate => format_option(torrent.done_date),
Self::DownloadDir => torrent.download_dir.clone().unwrap_or_default(), Self::DownloadDir => torrent.download_dir.clone().unwrap_or_default(),
Self::DownloadLimit => NetSpeed::from(torrent.download_limit).to_string(), Self::DownloadLimit => NetSpeed::from(torrent.download_limit).to_string(),
Self::DownloadLimited => format_option_string(torrent.download_limited), Self::DownloadLimited => format_option(torrent.download_limited),
Self::DownloadedEver => FileSize::from(torrent.downloaded_ever).to_string(), Self::DownloadedEver => FileSize::from(torrent.downloaded_ever).to_string(),
Self::EditDate => format_option_string(torrent.edit_date), Self::EditDate => format_option(torrent.edit_date),
Self::Error => torrent.error.format(), Self::Error => torrent.error.format(),
Self::ErrorString => torrent.error_string.clone().unwrap_or_default(), Self::ErrorString => torrent.error_string.clone().unwrap_or_default(),
Self::Eta => format_eta(torrent.eta), Self::Eta => format_eta(torrent.eta),
Self::EtaIdle => format_option_string(torrent.eta_idle), Self::EtaIdle => format_option(torrent.eta_idle),
Self::FileCount => format_option_string(torrent.file_count), Self::FileCount => format_option(torrent.file_count),
Self::FileStats => torrent.file_stats.format(), Self::FileStats => torrent.file_stats.format(),
Self::Files => torrent.files.format(), Self::Files => torrent.files.format(),
Self::Group => torrent.group.clone().unwrap_or_default(), Self::Group => torrent.group.clone().unwrap_or_default(),
Self::HashString => torrent.hash_string.clone().unwrap_or_default(), Self::HashString => torrent.hash_string.clone().unwrap_or_default(),
Self::HaveUnchecked => FileSize::from(torrent.have_unchecked).to_string(), Self::HaveUnchecked => FileSize::from(torrent.have_unchecked).to_string(),
Self::HaveValid => FileSize::from(torrent.have_valid).to_string(), Self::HaveValid => FileSize::from(torrent.have_valid).to_string(),
Self::HonorsSessionLimits => format_option_string(torrent.honors_session_limits), Self::HonorsSessionLimits => format_option(torrent.honors_session_limits),
Self::Id => format_option_string(torrent.id), Self::Id => format_option(torrent.id),
Self::IsFinished => format_option_string(torrent.is_finished), Self::IsFinished => format_option(torrent.is_finished),
Self::IsPrivate => format_option_string(torrent.is_private), Self::IsPrivate => format_option(torrent.is_private),
Self::IsStalled => format_option_string(torrent.is_stalled), Self::IsStalled => format_option(torrent.is_stalled),
Self::Labels => torrent.labels.clone().unwrap_or_default().join(", "), Self::Labels => torrent
.labels
.as_deref()
.map_or_else(String::new, |l| l.join(", ")),
Self::LeftUntilDone => FileSize::from(torrent.left_until_done).to_string(), Self::LeftUntilDone => FileSize::from(torrent.left_until_done).to_string(),
Self::MagnetLink => torrent.magnet_link.clone().unwrap_or_default(), Self::MagnetLink => torrent.magnet_link.clone().unwrap_or_default(),
Self::ManualAnnounceTime => format_option_string(torrent.manual_announce_time), Self::ManualAnnounceTime => format_option(torrent.manual_announce_time),
Self::MaxConnectedPeers => format_option_string(torrent.max_connected_peers), Self::MaxConnectedPeers => format_option(torrent.max_connected_peers),
Self::MetadataPercentComplete => torrent.metadata_percent_complete.format(), Self::MetadataPercentComplete => torrent.metadata_percent_complete.format(),
Self::Name => torrent.name.clone().unwrap_or_default(), Self::Name => torrent.name.clone().unwrap_or_default(),
Self::PeerLimit => format_option_string(torrent.peer_limit), Self::PeerLimit => format_option(torrent.peer_limit),
Self::Peers => torrent.peers.format(), Self::Peers => torrent.peers.format(),
Self::PeersConnected => format_option_string(torrent.peers_connected), Self::PeersConnected => format_option(torrent.peers_connected),
Self::PeersFrom => torrent Self::PeersFrom => torrent.peers_from.as_ref().map_or_else(String::new, |p| {
.peers_from format!(
.as_ref() "d:{} u:{} i:{} t:{}",
.map(|p| { p.from_dht, p.from_incoming, p.from_lpd, p.from_tracker
format!( )
"d:{} u:{} i:{} t:{}", }),
p.from_dht, p.from_incoming, p.from_lpd, p.from_tracker Self::PeersGettingFromUs => format_option(torrent.peers_getting_from_us),
) Self::PeersSendingToUs => format_option(torrent.peers_sending_to_us),
})
.unwrap_or_default(),
Self::PeersGettingFromUs => format_option_string(torrent.peers_getting_from_us),
Self::PeersSendingToUs => format_option_string(torrent.peers_sending_to_us),
Self::PercentComplete => torrent.percent_complete.format(), Self::PercentComplete => torrent.percent_complete.format(),
Self::PercentDone => torrent.percent_done.format(), Self::PercentDone => torrent.percent_done.format(),
Self::PieceCount => format_option_string(torrent.piece_count), Self::PieceCount => format_option(torrent.piece_count),
Self::PieceSize => FileSize::from(torrent.piece_size).to_string(), Self::PieceSize => FileSize::from(torrent.piece_size).to_string(),
Self::Pieces => torrent Self::Pieces => torrent
.pieces .pieces
.as_ref() .as_ref()
.map(|p| format!("{} bytes", p.len())) .map_or_else(String::new, |p| format!("{} bytes", p.len())),
.unwrap_or_default(),
Self::PrimaryMimeType => torrent.primary_mime_type.clone().unwrap_or_default(), Self::PrimaryMimeType => torrent.primary_mime_type.clone().unwrap_or_default(),
Self::Priorities => torrent.priorities.format(), Self::Priorities => torrent.priorities.format(),
Self::QueuePosition => format_option_string(torrent.queue_position), Self::QueuePosition => format_option(torrent.queue_position),
Self::RateDownload => NetSpeed::from(torrent.rate_download).to_string(), Self::RateDownload => NetSpeed::from(torrent.rate_download).to_string(),
Self::RateUpload => NetSpeed::from(torrent.rate_upload).to_string(), Self::RateUpload => NetSpeed::from(torrent.rate_upload).to_string(),
Self::RecheckProgress => torrent.recheck_progress.format(), Self::RecheckProgress => torrent.recheck_progress.format(),
Self::SecondsDownloading => format_option_string(torrent.seconds_downloading), Self::SecondsDownloading => format_option(torrent.seconds_downloading),
Self::SecondsSeeding => format_option_string(torrent.seconds_seeding), Self::SecondsSeeding => format_option(torrent.seconds_seeding),
Self::SeedIdleLimit => format_option_string(torrent.seed_idle_limit), Self::SeedIdleLimit => format_option(torrent.seed_idle_limit),
Self::SeedIdleMode => torrent.seed_idle_mode.format(), Self::SeedIdleMode => torrent.seed_idle_mode.format(),
Self::SeedRatioLimit => torrent.seed_ratio_limit.format(), Self::SeedRatioLimit => torrent.seed_ratio_limit.format(),
Self::SeedRatioMode => torrent.seed_ratio_mode.format(), Self::SeedRatioMode => torrent.seed_ratio_mode.format(),
Self::SequentialDownload => format_option_string(torrent.sequential_download), Self::SequentialDownload => format_option(torrent.sequential_download),
Self::SizeWhenDone => FileSize::from(torrent.size_when_done).to_string(), Self::SizeWhenDone => FileSize::from(torrent.size_when_done).to_string(),
Self::StartDate => format_option_string(torrent.start_date), Self::StartDate => format_option(torrent.start_date),
Self::Status => torrent.status.format(), Self::Status => torrent.status.format(),
Self::TorrentFile => torrent.torrent_file.clone().unwrap_or_default(), Self::TorrentFile => torrent.torrent_file.clone().unwrap_or_default(),
Self::TotalSize => FileSize::from(torrent.total_size).to_string(), Self::TotalSize => FileSize::from(torrent.total_size).to_string(),
@ -193,12 +191,15 @@ impl Wrapper for TorrentGetField {
Self::TrackerStats => torrent.tracker_stats.format(), Self::TrackerStats => torrent.tracker_stats.format(),
Self::Trackers => torrent.trackers.format(), Self::Trackers => torrent.trackers.format(),
Self::UploadLimit => NetSpeed::from(torrent.upload_limit).to_string(), Self::UploadLimit => NetSpeed::from(torrent.upload_limit).to_string(),
Self::UploadLimited => format_option_string(torrent.upload_limited), Self::UploadLimited => format_option(torrent.upload_limited),
Self::UploadRatio => torrent.upload_ratio.format(), Self::UploadRatio => torrent.upload_ratio.format(),
Self::UploadedEver => FileSize::from(torrent.uploaded_ever).to_string(), Self::UploadedEver => FileSize::from(torrent.uploaded_ever).to_string(),
Self::Wanted => torrent.wanted.format(), Self::Wanted => torrent.wanted.format(),
Self::Webseeds => torrent.webseeds.clone().unwrap_or_default().join(", "), Self::Webseeds => torrent
Self::WebseedsSendingToUs => format_option_string(torrent.webseeds_sending_to_us), .webseeds
.as_deref()
.map_or_else(String::new, |w| w.join(", ")),
Self::WebseedsSendingToUs => format_option(torrent.webseeds_sending_to_us),
} }
} }
@ -286,15 +287,15 @@ impl Wrapper for TorrentGetField {
} }
} }
fn format_option_string<T: Display>(value: Option<T>) -> String { fn format_option<T: Display>(value: Option<T>) -> String {
value.map(|v| v.to_string()).unwrap_or_default() value.map_or_else(String::new, |v| v.to_string())
} }
fn format_eta(value: Option<i64>) -> String { fn format_eta(value: Option<i64>) -> String {
match value { match value {
Some(-2) => "?".into(), Some(-2) => "?".into(),
None | Some(-1 | ..0) => String::new(), Some(v) if v > 0 => format!("{v} s"),
Some(v) => format!("{v} s"), _ => String::new(),
} }
} }
@ -304,15 +305,14 @@ trait Formatter {
impl Formatter for Option<f32> { impl Formatter for Option<f32> {
fn format(&self) -> String { fn format(&self) -> String {
self.map(|v| format!("{v:.2}")).unwrap_or_default() self.map_or_else(String::new, |v| format!("{v:.2}"))
} }
} }
impl<T> Formatter for Option<Vec<T>> { impl<T> Formatter for Option<Vec<T>> {
fn format(&self) -> String { fn format(&self) -> String {
self.as_ref() self.as_ref()
.map(|v| v.len().to_string()) .map_or_else(String::new, |v| v.len().to_string())
.unwrap_or_default()
} }
} }

View File

@ -1,8 +1,10 @@
use color_eyre::Result; use color_eyre::Result;
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent}; use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent};
use std::sync::mpsc; use std::{
use std::thread; sync::mpsc,
use std::time::{Duration, Instant}; thread,
time::{Duration, Instant},
};
use tracing::error; use tracing::error;
/// Terminal events. /// Terminal events.
@ -19,14 +21,10 @@ pub enum Event {
} }
/// Terminal event handler. /// Terminal event handler.
#[allow(dead_code)]
#[derive(Debug)] #[derive(Debug)]
pub struct EventHandler { pub struct EventHandler {
/// Event sender channel.
sender: mpsc::Sender<Event>,
/// Event receiver channel.
receiver: mpsc::Receiver<Event>, receiver: mpsc::Receiver<Event>,
/// Event handler thread. #[allow(dead_code)]
handler: thread::JoinHandle<()>, handler: thread::JoinHandle<()>,
} }
@ -35,46 +33,43 @@ impl EventHandler {
/// ///
/// # Panics /// # Panics
/// ///
/// TODO: add panic /// Panics if event polling or sending fails.
#[must_use] #[must_use]
pub fn new(tick_rate: u64) -> Self { pub fn new(tick_rate_ms: u64) -> Self {
let tick_rate = Duration::from_millis(tick_rate); let tick_rate = Duration::from_millis(tick_rate_ms);
let (sender, receiver) = mpsc::channel(); let (sender, receiver) = mpsc::channel();
let handler = {
let sender = sender.clone();
thread::spawn(move || {
let mut last_tick = Instant::now();
loop {
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or(tick_rate);
if event::poll(timeout).expect("no events available") { let handler = thread::spawn(move || {
match event::read() { let mut last_tick = Instant::now();
Ok(CrosstermEvent::Key(e)) => sender.send(Event::Key(e)), loop {
Ok(CrosstermEvent::Mouse(e)) => sender.send(Event::Mouse(e)), let timeout = tick_rate.saturating_sub(last_tick.elapsed());
Ok(CrosstermEvent::Resize(w, h)) => sender.send(Event::Resize(w, h)),
Err(e) => { if event::poll(timeout).expect("event polling failed") {
error!("Error reading event: {:?}", e); let send_result = match event::read() {
break; Ok(CrosstermEvent::Key(e)) => sender.send(Event::Key(e)),
} Ok(CrosstermEvent::Mouse(e)) => sender.send(Event::Mouse(e)),
_ => Ok(()), // Ignore other events Ok(CrosstermEvent::Resize(w, h)) => sender.send(Event::Resize(w, h)),
Ok(_) => Ok(()),
Err(e) => {
error!("Error reading event: {e:?}");
break;
} }
.expect("failed to send terminal event"); };
} if send_result.is_err() {
break;
if last_tick.elapsed() >= tick_rate {
sender.send(Event::Tick).expect("failed to send tick event");
last_tick = Instant::now();
} }
} }
})
}; if last_tick.elapsed() >= tick_rate {
Self { if sender.send(Event::Tick).is_err() {
sender, break;
receiver, }
handler, last_tick = Instant::now();
} }
}
});
Self { receiver, handler }
} }
/// Receive the next event from the handler thread. /// Receive the next event from the handler thread.
@ -84,7 +79,7 @@ impl EventHandler {
/// ///
/// # Errors /// # Errors
/// ///
/// TODO: add error types /// Returns an error if the sender is disconnected.
pub fn next(&self) -> Result<Event> { pub fn next(&self) -> Result<Event> {
Ok(self.receiver.recv()?) Ok(self.receiver.recv()?)
} }

View File

@ -40,7 +40,7 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Act
let keybinds = &app.config.keybinds; let keybinds = &app.config.keybinds;
let actions = [ Ok([
(Action::Quit, &keybinds.quit), (Action::Quit, &keybinds.quit),
(Action::NextTab, &keybinds.next_tab), (Action::NextTab, &keybinds.next_tab),
(Action::PrevTab, &keybinds.prev_tab), (Action::PrevTab, &keybinds.prev_tab),
@ -56,14 +56,9 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Act
(Action::Select, &keybinds.select), (Action::Select, &keybinds.select),
(Action::ToggleHelp, &keybinds.toggle_help), (Action::ToggleHelp, &keybinds.toggle_help),
(Action::Move, &keybinds.move_torrent), (Action::Move, &keybinds.move_torrent),
]; ]
.into_iter()
for (action, keybind) in actions { .find_map(|(action, keybind)| matches_keybind(&key_event, keybind).then_some(action)))
if matches_keybind(&key_event, keybind) {
return Ok(Some(action));
}
}
Ok(None)
} }
/// Handles the updates of [`App`]. /// Handles the updates of [`App`].
@ -101,14 +96,12 @@ pub async fn update(app: &mut App, action: Action) -> Result<()> {
/// Check if a [`KeyEvent`] matches a configured keybind string /// Check if a [`KeyEvent`] matches a configured keybind string
fn matches_keybind(event: &KeyEvent, config_key: &str) -> bool { fn matches_keybind(event: &KeyEvent, config_key: &str) -> bool {
parse_keybind(config_key) parse_keybind(config_key).is_ok_and(|parsed| parsed == *event)
.map(|parsed_ev| parsed_ev == *event)
.unwrap_or(false)
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ParseKeybingError { pub enum ParseKeybindError {
/// No “main” key was found (e.g. the user only wrote modifiers). /// No "main" key was found (e.g. the user only wrote modifiers).
#[error("no main key was found in input")] #[error("no main key was found in input")]
NoKeyCode, NoKeyCode,
/// An unrecognized token was encountered. /// An unrecognized token was encountered.
@ -116,7 +109,7 @@ pub enum ParseKeybingError {
UnknownPart(String), UnknownPart(String),
} }
fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybingError> { fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybindError> {
let mut modifiers = KeyModifiers::NONE; let mut modifiers = KeyModifiers::NONE;
let mut key_code = None; let mut key_code = None;
@ -128,8 +121,8 @@ fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybingErr
} }
continue; continue;
} }
let low = part.to_lowercase();
match low.as_str() { match part.to_ascii_lowercase().as_str() {
// modifiers // modifiers
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL, "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"shift" => modifiers |= KeyModifiers::SHIFT, "shift" => modifiers |= KeyModifiers::SHIFT,
@ -168,27 +161,25 @@ fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybingErr
"apostrophe" => key_code = Some(KeyCode::Char('\'')), "apostrophe" => key_code = Some(KeyCode::Char('\'')),
// function keys F1...F<N> // function keys F1...F<N>
f if f.starts_with('f') && f.len() > 1 => { f if f.starts_with('f') => {
let num_str = &f[1..]; key_code = Some(KeyCode::F(
match num_str.parse::<u8>() { f[1..]
Ok(n) => key_code = Some(KeyCode::F(n)), .parse()
Err(_) => return Err(ParseKeybingError::UnknownPart(part.to_owned())), .map_err(|_| ParseKeybindError::UnknownPart(part.to_owned()))?,
} ));
} }
// singlecharacter fallback // single-character fallback
_ if part.len() == 1 => { _ if part.len() == 1 => {
if let Some(ch) = part.chars().next() { key_code = part.chars().next().map(KeyCode::Char);
key_code = Some(KeyCode::Char(ch));
}
} }
// unknown token // unknown token
other => return Err(ParseKeybingError::UnknownPart(other.to_owned())), other => return Err(ParseKeybindError::UnknownPart(other.to_owned())),
} }
} }
key_code key_code
.map(|kc| KeyEvent::new(kc, modifiers)) .map(|kc| KeyEvent::new(kc, modifiers))
.ok_or(ParseKeybingError::NoKeyCode) .ok_or(ParseKeybindError::NoKeyCode)
} }

View File

@ -3,7 +3,6 @@ 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::warn; use tracing::warn;
@ -26,8 +25,7 @@ async fn main() -> Result<()> {
setup_logger(&config)?; setup_logger(&config)?;
let app = Arc::new(Mutex::new(App::new(config)?)); let app = Arc::new(Mutex::new(App::new(config)?));
spawn_torrent_updater(Arc::clone(&app));
spawn_torrent_updater(app.clone());
let backend = CrosstermBackend::new(io::stderr()); let backend = CrosstermBackend::new(io::stderr());
let terminal = Terminal::new(backend)?; let terminal = Terminal::new(backend)?;
@ -36,42 +34,32 @@ async fn main() -> Result<()> {
tui.init()?; tui.init()?;
loop { loop {
{ let mut app_guard = app.lock().await;
let app_guard = app.lock().await; if !app_guard.running {
if !app_guard.running { break;
break;
}
} }
tui.draw(&mut app_guard)?;
drop(app_guard);
{ if let Event::Key(key_event) = tui.events.next()? {
let mut app_guard = app.lock().await; let mut app_guard = app.lock().await;
tui.draw(&mut app_guard)?; if let Some(action) = get_action(key_event, &mut app_guard).await? {
} update(&mut app_guard, action).await?;
match tui.events.next()? {
Event::Key(key_event) => {
let mut app_guard = app.lock().await;
if let Some(action) = get_action(key_event, &mut app_guard).await? {
update(&mut app_guard, action).await?;
}
} }
Event::Mouse(_) | Event::Resize(_, _) | Event::Tick => {}
} }
} }
tui.exit()?; tui.exit()
Ok(())
} }
fn spawn_torrent_updater(app: Arc<Mutex<App>>) -> JoinHandle<()> { fn spawn_torrent_updater(app: Arc<Mutex<App>>) {
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(TORRENT_UPDATE_INTERVAL_SECS)); let mut interval = time::interval(Duration::from_secs(TORRENT_UPDATE_INTERVAL_SECS));
loop { loop {
interval.tick().await; interval.tick().await;
let mut app = app.lock().await; if let Err(e) = app.lock().await.torrents.update().await {
if let Err(e) = app.torrents.update().await {
warn!("Failed to update torrents: {e}"); warn!("Failed to update torrents: {e}");
} }
} }
}) });
} }