diff --git a/Cargo.lock b/Cargo.lock index 5fa81a0..0c37c93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,9 +89,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bumpalo" diff --git a/src/app/command.rs b/src/app/command.rs index 087b5f3..65ca612 100644 --- a/src/app/command.rs +++ b/src/app/command.rs @@ -1,92 +1,105 @@ use super::{Torrents, types::Selected}; use crate::error::Result; use std::{collections::HashSet, path::Path}; -use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus}; +use transmission_rpc::types::{Id, Torrent, TorrentAction, TorrentStatus}; impl Torrents { + /// Toggle selected torrents between started and stopped states. + /// /// # Errors /// - /// TODO: add error types + /// Returns an error if the RPC call fails. pub async fn toggle(&mut self, ids: Selected) -> Result<()> { - let ids: HashSet<_> = ids.into(); - let torrents_to_toggle = self + let selected: HashSet<_> = ids.into(); + + let (to_start, to_stop): (Vec<_>, Vec<_>) = self .torrents .iter() - .filter(|torrent| torrent.id.is_some_and(|id| ids.contains(&id))) - .collect::>(); - - 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, - }, - ) - }) + .filter_map(|t| { + t.id.filter(|id| selected.contains(id)) + .map(|id| (Id::Id(id), t.status)) }) - .collect(); + .partition(|(_, status)| matches!(status, Some(TorrentStatus::Stopped))); - for (id, action) in torrents_to_toggle { - self.client.torrent_action(action, vec![id]).await?; + if !to_start.is_empty() { + 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(()) } + /// Toggle all torrents between started and stopped states. + /// /// # 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<()> { self.action_all(TorrentAction::StartNow).await } + /// Stop all torrents. + /// /// # Errors /// - /// TODO: add error types + /// Returns an error if the RPC call fails. pub async fn stop_all(&mut self) -> Result<()> { self.action_all(TorrentAction::Stop).await } + /// Move a torrent to a new location. + /// /// # Errors /// - /// TODO: add error types + /// Returns an error if the RPC call fails. pub async fn move_dir( &mut self, torrent: &Torrent, location: &Path, move_from: Option, ) -> Result<()> { - if let Some(id) = torrent.id() { - self.client - .torrent_set_location(vec![id], location.to_string_lossy().into(), move_from) - .await?; - } + let Some(id) = torrent.id() else { + return Ok(()); + }; + self.client + .torrent_set_location(vec![id], location.to_string_lossy().into_owned(), move_from) + .await?; Ok(()) } + /// Delete torrents, optionally removing local data. + /// /// # 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<()> { self.client .torrent_remove(ids.into(), delete_local_data) @@ -94,29 +107,26 @@ impl Torrents { Ok(()) } + /// Rename a torrent. + /// /// # Errors /// - /// TODO: add error types + /// Returns an error if the RPC call fails. pub async fn rename(&mut self, torrent: &Torrent, name: &Path) -> Result<()> { - if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) { - self.client - .torrent_rename_path(vec![id], old_name, name.to_string_lossy().into()) - .await?; + let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.as_ref()) else { + return Ok(()); + }; + 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(()) } - - /// # Errors - /// - /// TODO: add error types - async fn action_all(&mut self, action: TorrentAction) -> Result<()> { - let ids = self - .torrents - .iter() - .filter_map(Torrent::id) - .collect::>(); - - self.client.torrent_action(action, ids).await?; - Ok(()) - } } diff --git a/src/app/input.rs b/src/app/input.rs index 18b80d5..e3e45f9 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -1,28 +1,37 @@ use crate::error::Result; -use std::path::{Path, PathBuf}; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; use tokio::fs; #[derive(Debug, Default)] pub struct InputHandler { pub text: String, pub cursor_position: usize, - pub completions: Vec, - pub completion_idx: usize, + completions: Vec, + completion_idx: usize, } impl InputHandler { + #[inline] + #[must_use] 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; + self.cursor_position += ch.len_utf8(); } pub fn delete_char(&mut self) { 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); } } @@ -40,16 +49,16 @@ impl InputHandler { } 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 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_from_completions(); + self.apply_completion(); Ok(()) } - fn update_completions(&mut self, matches: Vec) { + fn update_completions(&mut self, matches: Vec) { if matches.is_empty() { self.completions.clear(); self.completion_idx = 0; @@ -61,41 +70,36 @@ impl InputHandler { } } - fn update_from_completions(&mut self) { - if let Some(completions) = self.completions.get(self.completion_idx) { - self.set_text(completions.clone()); + fn apply_completion(&mut self) { + if let Some(path) = self.completions.get(self.completion_idx) { + 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() { - return (path, String::new()); + return (path, OsStr::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(); + let partial = path.file_name().unwrap_or_default(); + let base = path.parent().unwrap_or_else(|| Path::new("/")); (base, partial) } -async fn find_matching_entries(base_path: &Path, partial_name: &str) -> Result> { - let mut entries = fs::read_dir(&base_path).await?; +async fn find_matching_entries(base_path: &Path, partial_name: &OsStr) -> Result> { + let partial_lower = partial_name.to_string_lossy().to_lowercase(); + 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(); + let file_name = entry.file_name(); if file_name + .to_string_lossy() .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) diff --git a/src/app/mod.rs b/src/app/mod.rs index 24d4a57..4c5d3f1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -73,31 +73,27 @@ impl App { } pub fn next(&mut self) { - let i = match self.state.selected() { - Some(i) => { - if i >= self.torrents.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; + let len = self.torrents.len(); + if len == 0 { + return; + } + let i = self + .state + .selected() + .map_or(0, |i| if i >= len - 1 { 0 } else { i + 1 }); self.close_help(); self.state.select(Some(i)); } pub fn previous(&mut self) { - let i = match self.state.selected() { - Some(i) => { - if i == 0 { - self.torrents.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; + let len = self.torrents.len(); + if len == 0 { + return; + } + let i = self + .state + .selected() + .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 }); self.close_help(); self.state.select(Some(i)); } @@ -111,13 +107,9 @@ impl App { /// Switches to the previous tab. #[inline] - pub const fn prev_tab(&mut self) { + pub fn prev_tab(&mut self) { self.close_help(); - if self.index > 0 { - self.index -= 1; - } else { - self.index = self.tabs.len() - 1; - } + self.index = self.index.checked_sub(1).unwrap_or(self.tabs.len() - 1); } /// Switches to the tab whose index is `idx`. @@ -189,9 +181,9 @@ impl App { } 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 - .set_text(download_dir.to_string_lossy().to_string()); + .set_text(download_dir.to_string_lossy().into_owned()); } self.input_mode = true; } @@ -209,32 +201,31 @@ impl App { fn selected(&self, highlighted: bool) -> Selected { let torrents = &self.torrents.torrents; - if self.torrents.selected.is_empty() || highlighted { - let selected_id = self + if (self.torrents.selected.is_empty() || highlighted) + && let Some(id) = self .state .selected() - .and_then(|idx| torrents.get(idx).and_then(|t| t.id)); - if let Some(id) = selected_id { - return Selected::Current(id); - } + .and_then(|idx| torrents.get(idx).and_then(|t| t.id)) + { + return Selected::Current(id); } - let selected_torrents = torrents - .iter() - .filter_map(|t| t.id.filter(|id| self.torrents.selected.contains(id))) - .collect(); - Selected::List(selected_torrents) + Selected::List( + torrents + .iter() + .filter_map(|t| t.id.filter(|id| self.torrents.selected.contains(id))) + .collect(), + ) } - fn get_current_downlaod_dir(&self) -> Option { - match self.selected(true) { - Selected::Current(current_id) => self - .torrents - .torrents - .iter() - .find(|&t| t.id == Some(current_id)) - .and_then(|t| t.download_dir.as_ref()) - .map(PathBuf::from), - Selected::List(_) => None, - } + fn get_current_download_dir(&self) -> Option { + let Selected::Current(current_id) = self.selected(true) else { + return None; + }; + self.torrents + .torrents + .iter() + .find(|t| t.id == Some(current_id)) + .and_then(|t| t.download_dir.as_deref()) + .map(PathBuf::from) } } diff --git a/src/app/types.rs b/src/app/types.rs index 24c1c1f..77f484b 100644 --- a/src/app/types.rs +++ b/src/app/types.rs @@ -11,6 +11,23 @@ pub enum Selected { List(HashSet), } +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)] pub enum SelectedIntoIter { One(Once), @@ -20,18 +37,30 @@ pub enum SelectedIntoIter { impl Iterator for SelectedIntoIter { type Item = i64; + #[inline] fn next(&mut self) -> Option { match self { Self::One(it) => it.next(), Self::Many(it) => it.next(), } } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + match self { + Self::One(it) => it.size_hint(), + Self::Many(it) => it.size_hint(), + } + } } +impl ExactSizeIterator for SelectedIntoIter {} + impl IntoIterator for Selected { type Item = i64; type IntoIter = SelectedIntoIter; + #[inline] fn into_iter(self) -> Self::IntoIter { match self { Self::Current(id) => SelectedIntoIter::One(std::iter::once(id)), @@ -54,6 +83,7 @@ impl From for Vec { value.into_iter().collect() } } + impl From for Vec { fn from(value: Selected) -> Self { value.into_iter().map(Id::Id).collect() diff --git a/src/app/utils/mod.rs b/src/app/utils/mod.rs index ef367ea..5833da5 100644 --- a/src/app/utils/mod.rs +++ b/src/app/utils/mod.rs @@ -109,83 +109,81 @@ impl Wrapper for TorrentGetField { fn value(&self, torrent: &Torrent) -> String { match self { - Self::ActivityDate => format_option_string(torrent.activity_date), - Self::AddedDate => format_option_string(torrent.added_date), - Self::Availability => "N/A".to_string(), + Self::ActivityDate => format_option(torrent.activity_date), + Self::AddedDate => format_option(torrent.added_date), + Self::Availability => "N/A".into(), Self::BandwidthPriority => torrent.bandwidth_priority.format(), Self::Comment => torrent.comment.clone().unwrap_or_default(), Self::CorruptEver => FileSize::from(torrent.corrupt_ever).to_string(), 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::DoneDate => format_option_string(torrent.done_date), + Self::DoneDate => format_option(torrent.done_date), Self::DownloadDir => torrent.download_dir.clone().unwrap_or_default(), 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::EditDate => format_option_string(torrent.edit_date), + Self::EditDate => format_option(torrent.edit_date), Self::Error => torrent.error.format(), Self::ErrorString => torrent.error_string.clone().unwrap_or_default(), Self::Eta => format_eta(torrent.eta), - Self::EtaIdle => format_option_string(torrent.eta_idle), - Self::FileCount => format_option_string(torrent.file_count), + Self::EtaIdle => format_option(torrent.eta_idle), + Self::FileCount => format_option(torrent.file_count), Self::FileStats => torrent.file_stats.format(), Self::Files => torrent.files.format(), Self::Group => torrent.group.clone().unwrap_or_default(), Self::HashString => torrent.hash_string.clone().unwrap_or_default(), Self::HaveUnchecked => FileSize::from(torrent.have_unchecked).to_string(), Self::HaveValid => FileSize::from(torrent.have_valid).to_string(), - Self::HonorsSessionLimits => format_option_string(torrent.honors_session_limits), - Self::Id => format_option_string(torrent.id), - Self::IsFinished => format_option_string(torrent.is_finished), - Self::IsPrivate => format_option_string(torrent.is_private), - Self::IsStalled => format_option_string(torrent.is_stalled), - Self::Labels => torrent.labels.clone().unwrap_or_default().join(", "), + Self::HonorsSessionLimits => format_option(torrent.honors_session_limits), + Self::Id => format_option(torrent.id), + Self::IsFinished => format_option(torrent.is_finished), + Self::IsPrivate => format_option(torrent.is_private), + Self::IsStalled => format_option(torrent.is_stalled), + 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::MagnetLink => torrent.magnet_link.clone().unwrap_or_default(), - Self::ManualAnnounceTime => format_option_string(torrent.manual_announce_time), - Self::MaxConnectedPeers => format_option_string(torrent.max_connected_peers), + Self::ManualAnnounceTime => format_option(torrent.manual_announce_time), + Self::MaxConnectedPeers => format_option(torrent.max_connected_peers), Self::MetadataPercentComplete => torrent.metadata_percent_complete.format(), 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::PeersConnected => format_option_string(torrent.peers_connected), - Self::PeersFrom => torrent - .peers_from - .as_ref() - .map(|p| { - format!( - "d:{} u:{} i:{} t:{}", - p.from_dht, p.from_incoming, p.from_lpd, p.from_tracker - ) - }) - .unwrap_or_default(), - Self::PeersGettingFromUs => format_option_string(torrent.peers_getting_from_us), - Self::PeersSendingToUs => format_option_string(torrent.peers_sending_to_us), + Self::PeersConnected => format_option(torrent.peers_connected), + Self::PeersFrom => torrent.peers_from.as_ref().map_or_else(String::new, |p| { + 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), Self::PercentComplete => torrent.percent_complete.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::Pieces => torrent .pieces .as_ref() - .map(|p| format!("{} bytes", p.len())) - .unwrap_or_default(), + .map_or_else(String::new, |p| format!("{} bytes", p.len())), Self::PrimaryMimeType => torrent.primary_mime_type.clone().unwrap_or_default(), 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::RateUpload => NetSpeed::from(torrent.rate_upload).to_string(), Self::RecheckProgress => torrent.recheck_progress.format(), - Self::SecondsDownloading => format_option_string(torrent.seconds_downloading), - Self::SecondsSeeding => format_option_string(torrent.seconds_seeding), - Self::SeedIdleLimit => format_option_string(torrent.seed_idle_limit), + Self::SecondsDownloading => format_option(torrent.seconds_downloading), + Self::SecondsSeeding => format_option(torrent.seconds_seeding), + Self::SeedIdleLimit => format_option(torrent.seed_idle_limit), Self::SeedIdleMode => torrent.seed_idle_mode.format(), Self::SeedRatioLimit => torrent.seed_ratio_limit.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::StartDate => format_option_string(torrent.start_date), + Self::StartDate => format_option(torrent.start_date), Self::Status => torrent.status.format(), Self::TorrentFile => torrent.torrent_file.clone().unwrap_or_default(), Self::TotalSize => FileSize::from(torrent.total_size).to_string(), @@ -193,12 +191,15 @@ impl Wrapper for TorrentGetField { Self::TrackerStats => torrent.tracker_stats.format(), Self::Trackers => torrent.trackers.format(), 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::UploadedEver => FileSize::from(torrent.uploaded_ever).to_string(), Self::Wanted => torrent.wanted.format(), - Self::Webseeds => torrent.webseeds.clone().unwrap_or_default().join(", "), - Self::WebseedsSendingToUs => format_option_string(torrent.webseeds_sending_to_us), + Self::Webseeds => torrent + .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(value: Option) -> String { - value.map(|v| v.to_string()).unwrap_or_default() +fn format_option(value: Option) -> String { + value.map_or_else(String::new, |v| v.to_string()) } fn format_eta(value: Option) -> String { match value { Some(-2) => "?".into(), - None | Some(-1 | ..0) => String::new(), - Some(v) => format!("{v} s"), + Some(v) if v > 0 => format!("{v} s"), + _ => String::new(), } } @@ -304,15 +305,14 @@ trait Formatter { impl Formatter for Option { fn format(&self) -> String { - self.map(|v| format!("{v:.2}")).unwrap_or_default() + self.map_or_else(String::new, |v| format!("{v:.2}")) } } impl Formatter for Option> { fn format(&self) -> String { self.as_ref() - .map(|v| v.len().to_string()) - .unwrap_or_default() + .map_or_else(String::new, |v| v.len().to_string()) } } diff --git a/src/event.rs b/src/event.rs index dce5d9d..3511132 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,8 +1,10 @@ use color_eyre::Result; use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent}; -use std::sync::mpsc; -use std::thread; -use std::time::{Duration, Instant}; +use std::{ + sync::mpsc, + thread, + time::{Duration, Instant}, +}; use tracing::error; /// Terminal events. @@ -19,14 +21,10 @@ pub enum Event { } /// Terminal event handler. -#[allow(dead_code)] #[derive(Debug)] pub struct EventHandler { - /// Event sender channel. - sender: mpsc::Sender, - /// Event receiver channel. receiver: mpsc::Receiver, - /// Event handler thread. + #[allow(dead_code)] handler: thread::JoinHandle<()>, } @@ -35,46 +33,43 @@ impl EventHandler { /// /// # Panics /// - /// TODO: add panic + /// Panics if event polling or sending fails. #[must_use] - pub fn new(tick_rate: u64) -> Self { - let tick_rate = Duration::from_millis(tick_rate); + pub fn new(tick_rate_ms: u64) -> Self { + let tick_rate = Duration::from_millis(tick_rate_ms); 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") { - match event::read() { - Ok(CrosstermEvent::Key(e)) => sender.send(Event::Key(e)), - Ok(CrosstermEvent::Mouse(e)) => sender.send(Event::Mouse(e)), - Ok(CrosstermEvent::Resize(w, h)) => sender.send(Event::Resize(w, h)), - Err(e) => { - error!("Error reading event: {:?}", e); - break; - } - _ => Ok(()), // Ignore other events + let handler = thread::spawn(move || { + let mut last_tick = Instant::now(); + loop { + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + + if event::poll(timeout).expect("event polling failed") { + let send_result = match event::read() { + Ok(CrosstermEvent::Key(e)) => sender.send(Event::Key(e)), + Ok(CrosstermEvent::Mouse(e)) => sender.send(Event::Mouse(e)), + 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 last_tick.elapsed() >= tick_rate { - sender.send(Event::Tick).expect("failed to send tick event"); - last_tick = Instant::now(); + }; + if send_result.is_err() { + break; } } - }) - }; - Self { - sender, - receiver, - handler, - } + + if last_tick.elapsed() >= tick_rate { + if sender.send(Event::Tick).is_err() { + break; + } + last_tick = Instant::now(); + } + } + }); + + Self { receiver, handler } } /// Receive the next event from the handler thread. @@ -84,7 +79,7 @@ impl EventHandler { /// /// # Errors /// - /// TODO: add error types + /// Returns an error if the sender is disconnected. pub fn next(&self) -> Result { Ok(self.receiver.recv()?) } diff --git a/src/handler.rs b/src/handler.rs index b06890a..6db9421 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -40,7 +40,7 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result Result Result<()> { /// Check if a [`KeyEvent`] matches a configured keybind string fn matches_keybind(event: &KeyEvent, config_key: &str) -> bool { - parse_keybind(config_key) - .map(|parsed_ev| parsed_ev == *event) - .unwrap_or(false) + parse_keybind(config_key).is_ok_and(|parsed| parsed == *event) } #[derive(Debug, Error)] -pub enum ParseKeybingError { - /// No “main” key was found (e.g. the user only wrote modifiers). +pub enum ParseKeybindError { + /// No "main" key was found (e.g. the user only wrote modifiers). #[error("no main key was found in input")] NoKeyCode, /// An unrecognized token was encountered. @@ -116,7 +109,7 @@ pub enum ParseKeybingError { UnknownPart(String), } -fn parse_keybind(key_str: &str) -> std::result::Result { +fn parse_keybind(key_str: &str) -> std::result::Result { let mut modifiers = KeyModifiers::NONE; let mut key_code = None; @@ -128,8 +121,8 @@ fn parse_keybind(key_str: &str) -> std::result::Result modifiers |= KeyModifiers::CONTROL, "shift" => modifiers |= KeyModifiers::SHIFT, @@ -168,27 +161,25 @@ fn parse_keybind(key_str: &str) -> std::result::Result key_code = Some(KeyCode::Char('\'')), // function keys F1...F - f if f.starts_with('f') && f.len() > 1 => { - let num_str = &f[1..]; - match num_str.parse::() { - Ok(n) => key_code = Some(KeyCode::F(n)), - Err(_) => return Err(ParseKeybingError::UnknownPart(part.to_owned())), - } + f if f.starts_with('f') => { + key_code = Some(KeyCode::F( + f[1..] + .parse() + .map_err(|_| ParseKeybindError::UnknownPart(part.to_owned()))?, + )); } - // single‐character fallback + // single-character fallback _ if part.len() == 1 => { - if let Some(ch) = part.chars().next() { - key_code = Some(KeyCode::Char(ch)); - } + key_code = part.chars().next().map(KeyCode::Char); } // unknown token - other => return Err(ParseKeybingError::UnknownPart(other.to_owned())), + other => return Err(ParseKeybindError::UnknownPart(other.to_owned())), } } key_code .map(|kc| KeyEvent::new(kc, modifiers)) - .ok_or(ParseKeybingError::NoKeyCode) + .ok_or(ParseKeybindError::NoKeyCode) } diff --git a/src/main.rs b/src/main.rs index c7438cf..a3f1f45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,6 @@ use ratatui::{Terminal, backend::CrosstermBackend}; use std::{io, sync::Arc}; use tokio::{ sync::Mutex, - task::JoinHandle, time::{self, Duration}, }; use tracing::warn; @@ -26,8 +25,7 @@ async fn main() -> Result<()> { setup_logger(&config)?; let app = Arc::new(Mutex::new(App::new(config)?)); - - spawn_torrent_updater(app.clone()); + spawn_torrent_updater(Arc::clone(&app)); let backend = CrosstermBackend::new(io::stderr()); let terminal = Terminal::new(backend)?; @@ -36,42 +34,32 @@ async fn main() -> Result<()> { tui.init()?; loop { - { - let app_guard = app.lock().await; - if !app_guard.running { - break; - } + let mut app_guard = app.lock().await; + if !app_guard.running { + 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; - tui.draw(&mut app_guard)?; - } - - 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?; - } + 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()?; - Ok(()) + tui.exit() } -fn spawn_torrent_updater(app: Arc>) -> JoinHandle<()> { +fn spawn_torrent_updater(app: Arc>) { 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 { + if let Err(e) = app.lock().await.torrents.update().await { warn!("Failed to update torrents: {e}"); } } - }) + }); }