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

View File

@ -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::<Vec<_>>();
.filter_map(|t| {
t.id.filter(|id| selected.contains(id))
.map(|id| (Id::Id(id), t.status))
})
.partition(|(_, status)| matches!(status, Some(TorrentStatus::Stopped)));
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?;
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 torrents_to_toggle: Vec<_> = self
let (to_start, to_stop): (Vec<_>, Vec<_>) = self
.torrents
.iter()
.filter_map(|torrent| {
torrent.id().map(|id| {
(
id,
match torrent.status {
Some(TorrentStatus::Stopped) => TorrentAction::StartNow,
_ => TorrentAction::Stop,
},
)
})
})
.collect();
.filter_map(|t| t.id().map(|id| (id, t.status)))
.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::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
///
/// TODO: add error types
/// 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<bool>,
) -> Result<()> {
if let Some(id) = torrent.id() {
let Some(id) = torrent.id() else {
return Ok(());
};
self.client
.torrent_set_location(vec![id], location.to_string_lossy().into(), move_from)
.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()) {
let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.as_ref()) else {
return Ok(());
};
self.client
.torrent_rename_path(vec![id], old_name, name.to_string_lossy().into())
.torrent_rename_path(vec![id], old_name.clone(), name.to_string_lossy().into_owned())
.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::<Vec<_>>();
let ids: Vec<_> = self.torrents.iter().filter_map(Torrent::id).collect();
if !ids.is_empty() {
self.client.torrent_action(action, ids).await?;
}
Ok(())
}
}

View File

@ -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<String>,
pub completion_idx: usize,
completions: Vec<PathBuf>,
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<String>) {
fn update_completions(&mut self, matches: Vec<PathBuf>) {
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<Vec<String>> {
let mut entries = fs::read_dir(&base_path).await?;
async fn find_matching_entries(base_path: &Path, partial_name: &OsStr) -> Result<Vec<PathBuf>> {
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)

View File

@ -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
let len = self.torrents.len();
if len == 0 {
return;
}
}
None => 0,
};
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
let len = self.torrents.len();
if len == 0 {
return;
}
}
None => 0,
};
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 {
.and_then(|idx| torrents.get(idx).and_then(|t| t.id))
{
return Selected::Current(id);
}
}
let selected_torrents = torrents
Selected::List(
torrents
.iter()
.filter_map(|t| t.id.filter(|id| self.torrents.selected.contains(id)))
.collect();
Selected::List(selected_torrents)
.collect(),
)
}
fn get_current_downlaod_dir(&self) -> Option<PathBuf> {
match self.selected(true) {
Selected::Current(current_id) => self
.torrents
fn get_current_download_dir(&self) -> Option<PathBuf> {
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_ref())
.map(PathBuf::from),
Selected::List(_) => None,
}
.find(|t| t.id == Some(current_id))
.and_then(|t| t.download_dir.as_deref())
.map(PathBuf::from)
}
}

View File

@ -11,6 +11,23 @@ pub enum Selected {
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)]
pub enum SelectedIntoIter {
One(Once<i64>),
@ -20,18 +37,30 @@ pub enum SelectedIntoIter {
impl Iterator for SelectedIntoIter {
type Item = i64;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::One(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 {
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<Selected> for Vec<i64> {
value.into_iter().collect()
}
}
impl From<Selected> for Vec<Id> {
fn from(value: Selected) -> Self {
value.into_iter().map(Id::Id).collect()

View File

@ -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| {
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
)
})
.unwrap_or_default(),
Self::PeersGettingFromUs => format_option_string(torrent.peers_getting_from_us),
Self::PeersSendingToUs => format_option_string(torrent.peers_sending_to_us),
}),
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<T: Display>(value: Option<T>) -> String {
value.map(|v| v.to_string()).unwrap_or_default()
fn format_option<T: Display>(value: Option<T>) -> String {
value.map_or_else(String::new, |v| v.to_string())
}
fn format_eta(value: Option<i64>) -> 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<f32> {
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>> {
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())
}
}

View File

@ -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>,
/// Event receiver channel.
receiver: mpsc::Receiver<Event>,
/// 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 handler = thread::spawn(move || {
let mut last_tick = Instant::now();
loop {
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or(tick_rate);
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if event::poll(timeout).expect("no events available") {
match event::read() {
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);
error!("Error reading event: {e:?}");
break;
}
_ => Ok(()), // Ignore other events
};
if send_result.is_err() {
break;
}
.expect("failed to send terminal event");
}
if last_tick.elapsed() >= tick_rate {
sender.send(Event::Tick).expect("failed to send tick event");
if sender.send(Event::Tick).is_err() {
break;
}
last_tick = Instant::now();
}
}
})
};
Self {
sender,
receiver,
handler,
}
});
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<Event> {
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 actions = [
Ok([
(Action::Quit, &keybinds.quit),
(Action::NextTab, &keybinds.next_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::ToggleHelp, &keybinds.toggle_help),
(Action::Move, &keybinds.move_torrent),
];
for (action, keybind) in actions {
if matches_keybind(&key_event, keybind) {
return Ok(Some(action));
}
}
Ok(None)
]
.into_iter()
.find_map(|(action, keybind)| matches_keybind(&key_event, keybind).then_some(action)))
}
/// 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
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<KeyEvent, ParseKeybingError> {
fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybindError> {
let mut modifiers = KeyModifiers::NONE;
let mut key_code = None;
@ -128,8 +121,8 @@ fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybingErr
}
continue;
}
let low = part.to_lowercase();
match low.as_str() {
match part.to_ascii_lowercase().as_str() {
// modifiers
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"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('\'')),
// function keys F1...F<N>
f if f.starts_with('f') && f.len() > 1 => {
let num_str = &f[1..];
match num_str.parse::<u8>() {
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()))?,
));
}
// singlecharacter 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)
}

View File

@ -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;
let mut app_guard = app.lock().await;
if !app_guard.running {
break;
}
}
{
let mut app_guard = app.lock().await;
tui.draw(&mut app_guard)?;
}
drop(app_guard);
match tui.events.next()? {
Event::Key(key_event) => {
if let Event::Key(key_event) = tui.events.next()? {
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()?;
Ok(())
tui.exit()
}
fn spawn_torrent_updater(app: Arc<Mutex<App>>) -> JoinHandle<()> {
fn spawn_torrent_updater(app: Arc<Mutex<App>>) {
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}");
}
}
})
});
}