mirror of
https://github.com/kristoferssolo/traxor.git
synced 2026-01-14 20:46:14 +00:00
refactor: improve idiomatic Rust patterns and optimize RPC calls
This commit is contained in:
parent
be542551f3
commit
d352c95221
4
Cargo.lock
generated
4
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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<_>>();
|
||||
|
||||
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<bool>,
|
||||
) -> 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::<Vec<_>>();
|
||||
|
||||
self.client.torrent_action(action, ids).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<PathBuf> {
|
||||
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<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_deref())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
81
src/event.rs
81
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>,
|
||||
/// 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 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<Event> {
|
||||
Ok(self.receiver.recv()?)
|
||||
}
|
||||
|
||||
@ -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()))?,
|
||||
));
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
38
src/main.rs
38
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<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}");
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user