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]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.9.1"
|
version = "2.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
|
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
|
|||||||
@ -1,92 +1,105 @@
|
|||||||
use super::{Torrents, types::Selected};
|
use super::{Torrents, types::Selected};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use std::{collections::HashSet, path::Path};
|
use std::{collections::HashSet, path::Path};
|
||||||
use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus};
|
use transmission_rpc::types::{Id, Torrent, TorrentAction, TorrentStatus};
|
||||||
|
|
||||||
impl Torrents {
|
impl Torrents {
|
||||||
|
/// Toggle selected torrents between started and stopped states.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// Returns an error if the RPC call fails.
|
||||||
pub async fn toggle(&mut self, ids: Selected) -> Result<()> {
|
pub async fn toggle(&mut self, ids: Selected) -> Result<()> {
|
||||||
let ids: HashSet<_> = ids.into();
|
let selected: HashSet<_> = ids.into();
|
||||||
let torrents_to_toggle = self
|
|
||||||
|
let (to_start, to_stop): (Vec<_>, Vec<_>) = self
|
||||||
.torrents
|
.torrents
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|torrent| torrent.id.is_some_and(|id| ids.contains(&id)))
|
.filter_map(|t| {
|
||||||
.collect::<Vec<_>>();
|
t.id.filter(|id| selected.contains(id))
|
||||||
|
.map(|id| (Id::Id(id), t.status))
|
||||||
for torrent in torrents_to_toggle {
|
|
||||||
let action = match torrent.status {
|
|
||||||
Some(TorrentStatus::Stopped) => TorrentAction::Start,
|
|
||||||
_ => TorrentAction::Stop,
|
|
||||||
};
|
|
||||||
if let Some(id) = torrent.id() {
|
|
||||||
self.client.torrent_action(action, vec![id]).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// TODO: add error types
|
|
||||||
pub async fn toggle_all(&mut self) -> Result<()> {
|
|
||||||
let torrents_to_toggle: Vec<_> = self
|
|
||||||
.torrents
|
|
||||||
.iter()
|
|
||||||
.filter_map(|torrent| {
|
|
||||||
torrent.id().map(|id| {
|
|
||||||
(
|
|
||||||
id,
|
|
||||||
match torrent.status {
|
|
||||||
Some(TorrentStatus::Stopped) => TorrentAction::StartNow,
|
|
||||||
_ => TorrentAction::Stop,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect();
|
.partition(|(_, status)| matches!(status, Some(TorrentStatus::Stopped)));
|
||||||
|
|
||||||
for (id, action) in torrents_to_toggle {
|
if !to_start.is_empty() {
|
||||||
self.client.torrent_action(action, vec![id]).await?;
|
let ids: Vec<_> = to_start.into_iter().map(|(id, _)| id).collect();
|
||||||
|
self.client.torrent_action(TorrentAction::Start, ids).await?;
|
||||||
|
}
|
||||||
|
if !to_stop.is_empty() {
|
||||||
|
let ids: Vec<_> = to_stop.into_iter().map(|(id, _)| id).collect();
|
||||||
|
self.client.torrent_action(TorrentAction::Stop, ids).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggle all torrents between started and stopped states.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// Returns an error if the RPC call fails.
|
||||||
|
pub async fn toggle_all(&mut self) -> Result<()> {
|
||||||
|
let (to_start, to_stop): (Vec<_>, Vec<_>) = self
|
||||||
|
.torrents
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| t.id().map(|id| (id, t.status)))
|
||||||
|
.partition(|(_, status)| matches!(status, Some(TorrentStatus::Stopped)));
|
||||||
|
|
||||||
|
if !to_start.is_empty() {
|
||||||
|
let ids: Vec<_> = to_start.into_iter().map(|(id, _)| id).collect();
|
||||||
|
self.client
|
||||||
|
.torrent_action(TorrentAction::StartNow, ids)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if !to_stop.is_empty() {
|
||||||
|
let ids: Vec<_> = to_stop.into_iter().map(|(id, _)| id).collect();
|
||||||
|
self.client.torrent_action(TorrentAction::Stop, ids).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start all torrents immediately.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the RPC call fails.
|
||||||
pub async fn start_all(&mut self) -> Result<()> {
|
pub async fn start_all(&mut self) -> Result<()> {
|
||||||
self.action_all(TorrentAction::StartNow).await
|
self.action_all(TorrentAction::StartNow).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop all torrents.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// Returns an error if the RPC call fails.
|
||||||
pub async fn stop_all(&mut self) -> Result<()> {
|
pub async fn stop_all(&mut self) -> Result<()> {
|
||||||
self.action_all(TorrentAction::Stop).await
|
self.action_all(TorrentAction::Stop).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Move a torrent to a new location.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// Returns an error if the RPC call fails.
|
||||||
pub async fn move_dir(
|
pub async fn move_dir(
|
||||||
&mut self,
|
&mut self,
|
||||||
torrent: &Torrent,
|
torrent: &Torrent,
|
||||||
location: &Path,
|
location: &Path,
|
||||||
move_from: Option<bool>,
|
move_from: Option<bool>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if let Some(id) = torrent.id() {
|
let Some(id) = torrent.id() else {
|
||||||
self.client
|
return Ok(());
|
||||||
.torrent_set_location(vec![id], location.to_string_lossy().into(), move_from)
|
};
|
||||||
.await?;
|
self.client
|
||||||
}
|
.torrent_set_location(vec![id], location.to_string_lossy().into_owned(), move_from)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete torrents, optionally removing local data.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// Returns an error if the RPC call fails.
|
||||||
pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> Result<()> {
|
pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> Result<()> {
|
||||||
self.client
|
self.client
|
||||||
.torrent_remove(ids.into(), delete_local_data)
|
.torrent_remove(ids.into(), delete_local_data)
|
||||||
@ -94,29 +107,26 @@ impl Torrents {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename a torrent.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// Returns an error if the RPC call fails.
|
||||||
pub async fn rename(&mut self, torrent: &Torrent, name: &Path) -> Result<()> {
|
pub async fn rename(&mut self, torrent: &Torrent, name: &Path) -> Result<()> {
|
||||||
if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) {
|
let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.as_ref()) else {
|
||||||
self.client
|
return Ok(());
|
||||||
.torrent_rename_path(vec![id], old_name, name.to_string_lossy().into())
|
};
|
||||||
.await?;
|
self.client
|
||||||
|
.torrent_rename_path(vec![id], old_name.clone(), name.to_string_lossy().into_owned())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn action_all(&mut self, action: TorrentAction) -> Result<()> {
|
||||||
|
let ids: Vec<_> = self.torrents.iter().filter_map(Torrent::id).collect();
|
||||||
|
if !ids.is_empty() {
|
||||||
|
self.client.torrent_action(action, ids).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// TODO: add error types
|
|
||||||
async fn action_all(&mut self, action: TorrentAction) -> Result<()> {
|
|
||||||
let ids = self
|
|
||||||
.torrents
|
|
||||||
.iter()
|
|
||||||
.filter_map(Torrent::id)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
self.client.torrent_action(action, ids).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,37 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use std::path::{Path, PathBuf};
|
use std::{
|
||||||
|
ffi::OsStr,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct InputHandler {
|
pub struct InputHandler {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub cursor_position: usize,
|
pub cursor_position: usize,
|
||||||
pub completions: Vec<String>,
|
completions: Vec<PathBuf>,
|
||||||
pub completion_idx: usize,
|
completion_idx: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InputHandler {
|
impl InputHandler {
|
||||||
|
#[inline]
|
||||||
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_char(&mut self, ch: char) {
|
pub fn insert_char(&mut self, ch: char) {
|
||||||
self.text.insert(self.cursor_position, ch);
|
self.text.insert(self.cursor_position, ch);
|
||||||
self.cursor_position += 1;
|
self.cursor_position += ch.len_utf8();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_char(&mut self) {
|
pub fn delete_char(&mut self) {
|
||||||
if self.cursor_position > 0 {
|
if self.cursor_position > 0 {
|
||||||
self.cursor_position -= 1;
|
let ch = self.text[..self.cursor_position]
|
||||||
|
.chars()
|
||||||
|
.next_back()
|
||||||
|
.expect("cursor position is valid");
|
||||||
|
self.cursor_position -= ch.len_utf8();
|
||||||
self.text.remove(self.cursor_position);
|
self.text.remove(self.cursor_position);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -40,16 +49,16 @@ impl InputHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn complete(&mut self) -> Result<()> {
|
pub async fn complete(&mut self) -> Result<()> {
|
||||||
let path = PathBuf::from(&self.text);
|
let path = Path::new(&self.text);
|
||||||
let (base_path, partial_name) = split_path_components(path);
|
let (base_path, partial_name) = split_path_components(path);
|
||||||
let matches = find_matching_entries(&base_path, &partial_name).await?;
|
let matches = find_matching_entries(base_path, partial_name).await?;
|
||||||
|
|
||||||
self.update_completions(matches);
|
self.update_completions(matches);
|
||||||
self.update_from_completions();
|
self.apply_completion();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_completions(&mut self, matches: Vec<String>) {
|
fn update_completions(&mut self, matches: Vec<PathBuf>) {
|
||||||
if matches.is_empty() {
|
if matches.is_empty() {
|
||||||
self.completions.clear();
|
self.completions.clear();
|
||||||
self.completion_idx = 0;
|
self.completion_idx = 0;
|
||||||
@ -61,41 +70,36 @@ impl InputHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_from_completions(&mut self) {
|
fn apply_completion(&mut self) {
|
||||||
if let Some(completions) = self.completions.get(self.completion_idx) {
|
if let Some(path) = self.completions.get(self.completion_idx) {
|
||||||
self.set_text(completions.clone());
|
self.set_text(path.to_string_lossy().into_owned());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn split_path_components(path: PathBuf) -> (PathBuf, String) {
|
fn split_path_components(path: &Path) -> (&Path, &OsStr) {
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
return (path, String::new());
|
return (path, OsStr::new(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
let partial = path
|
let partial = path.file_name().unwrap_or_default();
|
||||||
.file_name()
|
let base = path.parent().unwrap_or_else(|| Path::new("/"));
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
let base = path
|
|
||||||
.parent()
|
|
||||||
.unwrap_or_else(|| Path::new("/"))
|
|
||||||
.to_path_buf();
|
|
||||||
(base, partial)
|
(base, partial)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_matching_entries(base_path: &Path, partial_name: &str) -> Result<Vec<String>> {
|
async fn find_matching_entries(base_path: &Path, partial_name: &OsStr) -> Result<Vec<PathBuf>> {
|
||||||
let mut entries = fs::read_dir(&base_path).await?;
|
let partial_lower = partial_name.to_string_lossy().to_lowercase();
|
||||||
|
let mut entries = fs::read_dir(base_path).await?;
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
let file_name = entry.file_name();
|
||||||
if file_name
|
if file_name
|
||||||
|
.to_string_lossy()
|
||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
.starts_with(&partial_name.to_lowercase())
|
.starts_with(&partial_lower)
|
||||||
{
|
{
|
||||||
matches.push(format!("{}/{}", base_path.to_string_lossy(), file_name));
|
matches.push(base_path.join(file_name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(matches)
|
Ok(matches)
|
||||||
|
|||||||
@ -73,31 +73,27 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn next(&mut self) {
|
pub fn next(&mut self) {
|
||||||
let i = match self.state.selected() {
|
let len = self.torrents.len();
|
||||||
Some(i) => {
|
if len == 0 {
|
||||||
if i >= self.torrents.len() - 1 {
|
return;
|
||||||
0
|
}
|
||||||
} else {
|
let i = self
|
||||||
i + 1
|
.state
|
||||||
}
|
.selected()
|
||||||
}
|
.map_or(0, |i| if i >= len - 1 { 0 } else { i + 1 });
|
||||||
None => 0,
|
|
||||||
};
|
|
||||||
self.close_help();
|
self.close_help();
|
||||||
self.state.select(Some(i));
|
self.state.select(Some(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn previous(&mut self) {
|
pub fn previous(&mut self) {
|
||||||
let i = match self.state.selected() {
|
let len = self.torrents.len();
|
||||||
Some(i) => {
|
if len == 0 {
|
||||||
if i == 0 {
|
return;
|
||||||
self.torrents.len() - 1
|
}
|
||||||
} else {
|
let i = self
|
||||||
i - 1
|
.state
|
||||||
}
|
.selected()
|
||||||
}
|
.map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
|
||||||
None => 0,
|
|
||||||
};
|
|
||||||
self.close_help();
|
self.close_help();
|
||||||
self.state.select(Some(i));
|
self.state.select(Some(i));
|
||||||
}
|
}
|
||||||
@ -111,13 +107,9 @@ impl App {
|
|||||||
|
|
||||||
/// Switches to the previous tab.
|
/// Switches to the previous tab.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub const fn prev_tab(&mut self) {
|
pub fn prev_tab(&mut self) {
|
||||||
self.close_help();
|
self.close_help();
|
||||||
if self.index > 0 {
|
self.index = self.index.checked_sub(1).unwrap_or(self.tabs.len() - 1);
|
||||||
self.index -= 1;
|
|
||||||
} else {
|
|
||||||
self.index = self.tabs.len() - 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches to the tab whose index is `idx`.
|
/// Switches to the tab whose index is `idx`.
|
||||||
@ -189,9 +181,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn prepare_move_action(&mut self) {
|
pub fn prepare_move_action(&mut self) {
|
||||||
if let Some(download_dir) = self.get_current_downlaod_dir() {
|
if let Some(download_dir) = self.get_current_download_dir() {
|
||||||
self.input_handler
|
self.input_handler
|
||||||
.set_text(download_dir.to_string_lossy().to_string());
|
.set_text(download_dir.to_string_lossy().into_owned());
|
||||||
}
|
}
|
||||||
self.input_mode = true;
|
self.input_mode = true;
|
||||||
}
|
}
|
||||||
@ -209,32 +201,31 @@ impl App {
|
|||||||
|
|
||||||
fn selected(&self, highlighted: bool) -> Selected {
|
fn selected(&self, highlighted: bool) -> Selected {
|
||||||
let torrents = &self.torrents.torrents;
|
let torrents = &self.torrents.torrents;
|
||||||
if self.torrents.selected.is_empty() || highlighted {
|
if (self.torrents.selected.is_empty() || highlighted)
|
||||||
let selected_id = self
|
&& let Some(id) = self
|
||||||
.state
|
.state
|
||||||
.selected()
|
.selected()
|
||||||
.and_then(|idx| torrents.get(idx).and_then(|t| t.id));
|
.and_then(|idx| torrents.get(idx).and_then(|t| t.id))
|
||||||
if let Some(id) = selected_id {
|
{
|
||||||
return Selected::Current(id);
|
return Selected::Current(id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let selected_torrents = torrents
|
Selected::List(
|
||||||
.iter()
|
torrents
|
||||||
.filter_map(|t| t.id.filter(|id| self.torrents.selected.contains(id)))
|
.iter()
|
||||||
.collect();
|
.filter_map(|t| t.id.filter(|id| self.torrents.selected.contains(id)))
|
||||||
Selected::List(selected_torrents)
|
.collect(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_current_downlaod_dir(&self) -> Option<PathBuf> {
|
fn get_current_download_dir(&self) -> Option<PathBuf> {
|
||||||
match self.selected(true) {
|
let Selected::Current(current_id) = self.selected(true) else {
|
||||||
Selected::Current(current_id) => self
|
return None;
|
||||||
.torrents
|
};
|
||||||
.torrents
|
self.torrents
|
||||||
.iter()
|
.torrents
|
||||||
.find(|&t| t.id == Some(current_id))
|
.iter()
|
||||||
.and_then(|t| t.download_dir.as_ref())
|
.find(|t| t.id == Some(current_id))
|
||||||
.map(PathBuf::from),
|
.and_then(|t| t.download_dir.as_deref())
|
||||||
Selected::List(_) => None,
|
.map(PathBuf::from)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,23 @@ pub enum Selected {
|
|||||||
List(HashSet<i64>),
|
List(HashSet<i64>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Selected {
|
||||||
|
#[inline]
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Current(_) => 1,
|
||||||
|
Self::List(set) => set.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
matches!(self, Self::List(set) if set.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum SelectedIntoIter {
|
pub enum SelectedIntoIter {
|
||||||
One(Once<i64>),
|
One(Once<i64>),
|
||||||
@ -20,18 +37,30 @@ pub enum SelectedIntoIter {
|
|||||||
impl Iterator for SelectedIntoIter {
|
impl Iterator for SelectedIntoIter {
|
||||||
type Item = i64;
|
type Item = i64;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
fn next(&mut self) -> Option<Self::Item> {
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
match self {
|
match self {
|
||||||
Self::One(it) => it.next(),
|
Self::One(it) => it.next(),
|
||||||
Self::Many(it) => it.next(),
|
Self::Many(it) => it.next(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||||
|
match self {
|
||||||
|
Self::One(it) => it.size_hint(),
|
||||||
|
Self::Many(it) => it.size_hint(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ExactSizeIterator for SelectedIntoIter {}
|
||||||
|
|
||||||
impl IntoIterator for Selected {
|
impl IntoIterator for Selected {
|
||||||
type Item = i64;
|
type Item = i64;
|
||||||
type IntoIter = SelectedIntoIter;
|
type IntoIter = SelectedIntoIter;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
fn into_iter(self) -> Self::IntoIter {
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
match self {
|
match self {
|
||||||
Self::Current(id) => SelectedIntoIter::One(std::iter::once(id)),
|
Self::Current(id) => SelectedIntoIter::One(std::iter::once(id)),
|
||||||
@ -54,6 +83,7 @@ impl From<Selected> for Vec<i64> {
|
|||||||
value.into_iter().collect()
|
value.into_iter().collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Selected> for Vec<Id> {
|
impl From<Selected> for Vec<Id> {
|
||||||
fn from(value: Selected) -> Self {
|
fn from(value: Selected) -> Self {
|
||||||
value.into_iter().map(Id::Id).collect()
|
value.into_iter().map(Id::Id).collect()
|
||||||
|
|||||||
@ -109,83 +109,81 @@ impl Wrapper for TorrentGetField {
|
|||||||
|
|
||||||
fn value(&self, torrent: &Torrent) -> String {
|
fn value(&self, torrent: &Torrent) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::ActivityDate => format_option_string(torrent.activity_date),
|
Self::ActivityDate => format_option(torrent.activity_date),
|
||||||
Self::AddedDate => format_option_string(torrent.added_date),
|
Self::AddedDate => format_option(torrent.added_date),
|
||||||
Self::Availability => "N/A".to_string(),
|
Self::Availability => "N/A".into(),
|
||||||
Self::BandwidthPriority => torrent.bandwidth_priority.format(),
|
Self::BandwidthPriority => torrent.bandwidth_priority.format(),
|
||||||
Self::Comment => torrent.comment.clone().unwrap_or_default(),
|
Self::Comment => torrent.comment.clone().unwrap_or_default(),
|
||||||
Self::CorruptEver => FileSize::from(torrent.corrupt_ever).to_string(),
|
Self::CorruptEver => FileSize::from(torrent.corrupt_ever).to_string(),
|
||||||
Self::Creator => torrent.creator.clone().unwrap_or_default(),
|
Self::Creator => torrent.creator.clone().unwrap_or_default(),
|
||||||
Self::DateCreated => format_option_string(torrent.date_created),
|
Self::DateCreated => format_option(torrent.date_created),
|
||||||
Self::DesiredAvailable => FileSize::from(torrent.desired_available).to_string(),
|
Self::DesiredAvailable => FileSize::from(torrent.desired_available).to_string(),
|
||||||
Self::DoneDate => format_option_string(torrent.done_date),
|
Self::DoneDate => format_option(torrent.done_date),
|
||||||
Self::DownloadDir => torrent.download_dir.clone().unwrap_or_default(),
|
Self::DownloadDir => torrent.download_dir.clone().unwrap_or_default(),
|
||||||
Self::DownloadLimit => NetSpeed::from(torrent.download_limit).to_string(),
|
Self::DownloadLimit => NetSpeed::from(torrent.download_limit).to_string(),
|
||||||
Self::DownloadLimited => format_option_string(torrent.download_limited),
|
Self::DownloadLimited => format_option(torrent.download_limited),
|
||||||
Self::DownloadedEver => FileSize::from(torrent.downloaded_ever).to_string(),
|
Self::DownloadedEver => FileSize::from(torrent.downloaded_ever).to_string(),
|
||||||
Self::EditDate => format_option_string(torrent.edit_date),
|
Self::EditDate => format_option(torrent.edit_date),
|
||||||
Self::Error => torrent.error.format(),
|
Self::Error => torrent.error.format(),
|
||||||
Self::ErrorString => torrent.error_string.clone().unwrap_or_default(),
|
Self::ErrorString => torrent.error_string.clone().unwrap_or_default(),
|
||||||
Self::Eta => format_eta(torrent.eta),
|
Self::Eta => format_eta(torrent.eta),
|
||||||
Self::EtaIdle => format_option_string(torrent.eta_idle),
|
Self::EtaIdle => format_option(torrent.eta_idle),
|
||||||
Self::FileCount => format_option_string(torrent.file_count),
|
Self::FileCount => format_option(torrent.file_count),
|
||||||
Self::FileStats => torrent.file_stats.format(),
|
Self::FileStats => torrent.file_stats.format(),
|
||||||
Self::Files => torrent.files.format(),
|
Self::Files => torrent.files.format(),
|
||||||
Self::Group => torrent.group.clone().unwrap_or_default(),
|
Self::Group => torrent.group.clone().unwrap_or_default(),
|
||||||
Self::HashString => torrent.hash_string.clone().unwrap_or_default(),
|
Self::HashString => torrent.hash_string.clone().unwrap_or_default(),
|
||||||
Self::HaveUnchecked => FileSize::from(torrent.have_unchecked).to_string(),
|
Self::HaveUnchecked => FileSize::from(torrent.have_unchecked).to_string(),
|
||||||
Self::HaveValid => FileSize::from(torrent.have_valid).to_string(),
|
Self::HaveValid => FileSize::from(torrent.have_valid).to_string(),
|
||||||
Self::HonorsSessionLimits => format_option_string(torrent.honors_session_limits),
|
Self::HonorsSessionLimits => format_option(torrent.honors_session_limits),
|
||||||
Self::Id => format_option_string(torrent.id),
|
Self::Id => format_option(torrent.id),
|
||||||
Self::IsFinished => format_option_string(torrent.is_finished),
|
Self::IsFinished => format_option(torrent.is_finished),
|
||||||
Self::IsPrivate => format_option_string(torrent.is_private),
|
Self::IsPrivate => format_option(torrent.is_private),
|
||||||
Self::IsStalled => format_option_string(torrent.is_stalled),
|
Self::IsStalled => format_option(torrent.is_stalled),
|
||||||
Self::Labels => torrent.labels.clone().unwrap_or_default().join(", "),
|
Self::Labels => torrent
|
||||||
|
.labels
|
||||||
|
.as_deref()
|
||||||
|
.map_or_else(String::new, |l| l.join(", ")),
|
||||||
Self::LeftUntilDone => FileSize::from(torrent.left_until_done).to_string(),
|
Self::LeftUntilDone => FileSize::from(torrent.left_until_done).to_string(),
|
||||||
Self::MagnetLink => torrent.magnet_link.clone().unwrap_or_default(),
|
Self::MagnetLink => torrent.magnet_link.clone().unwrap_or_default(),
|
||||||
Self::ManualAnnounceTime => format_option_string(torrent.manual_announce_time),
|
Self::ManualAnnounceTime => format_option(torrent.manual_announce_time),
|
||||||
Self::MaxConnectedPeers => format_option_string(torrent.max_connected_peers),
|
Self::MaxConnectedPeers => format_option(torrent.max_connected_peers),
|
||||||
Self::MetadataPercentComplete => torrent.metadata_percent_complete.format(),
|
Self::MetadataPercentComplete => torrent.metadata_percent_complete.format(),
|
||||||
Self::Name => torrent.name.clone().unwrap_or_default(),
|
Self::Name => torrent.name.clone().unwrap_or_default(),
|
||||||
Self::PeerLimit => format_option_string(torrent.peer_limit),
|
Self::PeerLimit => format_option(torrent.peer_limit),
|
||||||
Self::Peers => torrent.peers.format(),
|
Self::Peers => torrent.peers.format(),
|
||||||
Self::PeersConnected => format_option_string(torrent.peers_connected),
|
Self::PeersConnected => format_option(torrent.peers_connected),
|
||||||
Self::PeersFrom => torrent
|
Self::PeersFrom => torrent.peers_from.as_ref().map_or_else(String::new, |p| {
|
||||||
.peers_from
|
format!(
|
||||||
.as_ref()
|
"d:{} u:{} i:{} t:{}",
|
||||||
.map(|p| {
|
p.from_dht, p.from_incoming, p.from_lpd, p.from_tracker
|
||||||
format!(
|
)
|
||||||
"d:{} u:{} i:{} t:{}",
|
}),
|
||||||
p.from_dht, p.from_incoming, p.from_lpd, p.from_tracker
|
Self::PeersGettingFromUs => format_option(torrent.peers_getting_from_us),
|
||||||
)
|
Self::PeersSendingToUs => format_option(torrent.peers_sending_to_us),
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
Self::PeersGettingFromUs => format_option_string(torrent.peers_getting_from_us),
|
|
||||||
Self::PeersSendingToUs => format_option_string(torrent.peers_sending_to_us),
|
|
||||||
Self::PercentComplete => torrent.percent_complete.format(),
|
Self::PercentComplete => torrent.percent_complete.format(),
|
||||||
Self::PercentDone => torrent.percent_done.format(),
|
Self::PercentDone => torrent.percent_done.format(),
|
||||||
Self::PieceCount => format_option_string(torrent.piece_count),
|
Self::PieceCount => format_option(torrent.piece_count),
|
||||||
Self::PieceSize => FileSize::from(torrent.piece_size).to_string(),
|
Self::PieceSize => FileSize::from(torrent.piece_size).to_string(),
|
||||||
Self::Pieces => torrent
|
Self::Pieces => torrent
|
||||||
.pieces
|
.pieces
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| format!("{} bytes", p.len()))
|
.map_or_else(String::new, |p| format!("{} bytes", p.len())),
|
||||||
.unwrap_or_default(),
|
|
||||||
Self::PrimaryMimeType => torrent.primary_mime_type.clone().unwrap_or_default(),
|
Self::PrimaryMimeType => torrent.primary_mime_type.clone().unwrap_or_default(),
|
||||||
Self::Priorities => torrent.priorities.format(),
|
Self::Priorities => torrent.priorities.format(),
|
||||||
Self::QueuePosition => format_option_string(torrent.queue_position),
|
Self::QueuePosition => format_option(torrent.queue_position),
|
||||||
Self::RateDownload => NetSpeed::from(torrent.rate_download).to_string(),
|
Self::RateDownload => NetSpeed::from(torrent.rate_download).to_string(),
|
||||||
Self::RateUpload => NetSpeed::from(torrent.rate_upload).to_string(),
|
Self::RateUpload => NetSpeed::from(torrent.rate_upload).to_string(),
|
||||||
Self::RecheckProgress => torrent.recheck_progress.format(),
|
Self::RecheckProgress => torrent.recheck_progress.format(),
|
||||||
Self::SecondsDownloading => format_option_string(torrent.seconds_downloading),
|
Self::SecondsDownloading => format_option(torrent.seconds_downloading),
|
||||||
Self::SecondsSeeding => format_option_string(torrent.seconds_seeding),
|
Self::SecondsSeeding => format_option(torrent.seconds_seeding),
|
||||||
Self::SeedIdleLimit => format_option_string(torrent.seed_idle_limit),
|
Self::SeedIdleLimit => format_option(torrent.seed_idle_limit),
|
||||||
Self::SeedIdleMode => torrent.seed_idle_mode.format(),
|
Self::SeedIdleMode => torrent.seed_idle_mode.format(),
|
||||||
Self::SeedRatioLimit => torrent.seed_ratio_limit.format(),
|
Self::SeedRatioLimit => torrent.seed_ratio_limit.format(),
|
||||||
Self::SeedRatioMode => torrent.seed_ratio_mode.format(),
|
Self::SeedRatioMode => torrent.seed_ratio_mode.format(),
|
||||||
Self::SequentialDownload => format_option_string(torrent.sequential_download),
|
Self::SequentialDownload => format_option(torrent.sequential_download),
|
||||||
Self::SizeWhenDone => FileSize::from(torrent.size_when_done).to_string(),
|
Self::SizeWhenDone => FileSize::from(torrent.size_when_done).to_string(),
|
||||||
Self::StartDate => format_option_string(torrent.start_date),
|
Self::StartDate => format_option(torrent.start_date),
|
||||||
Self::Status => torrent.status.format(),
|
Self::Status => torrent.status.format(),
|
||||||
Self::TorrentFile => torrent.torrent_file.clone().unwrap_or_default(),
|
Self::TorrentFile => torrent.torrent_file.clone().unwrap_or_default(),
|
||||||
Self::TotalSize => FileSize::from(torrent.total_size).to_string(),
|
Self::TotalSize => FileSize::from(torrent.total_size).to_string(),
|
||||||
@ -193,12 +191,15 @@ impl Wrapper for TorrentGetField {
|
|||||||
Self::TrackerStats => torrent.tracker_stats.format(),
|
Self::TrackerStats => torrent.tracker_stats.format(),
|
||||||
Self::Trackers => torrent.trackers.format(),
|
Self::Trackers => torrent.trackers.format(),
|
||||||
Self::UploadLimit => NetSpeed::from(torrent.upload_limit).to_string(),
|
Self::UploadLimit => NetSpeed::from(torrent.upload_limit).to_string(),
|
||||||
Self::UploadLimited => format_option_string(torrent.upload_limited),
|
Self::UploadLimited => format_option(torrent.upload_limited),
|
||||||
Self::UploadRatio => torrent.upload_ratio.format(),
|
Self::UploadRatio => torrent.upload_ratio.format(),
|
||||||
Self::UploadedEver => FileSize::from(torrent.uploaded_ever).to_string(),
|
Self::UploadedEver => FileSize::from(torrent.uploaded_ever).to_string(),
|
||||||
Self::Wanted => torrent.wanted.format(),
|
Self::Wanted => torrent.wanted.format(),
|
||||||
Self::Webseeds => torrent.webseeds.clone().unwrap_or_default().join(", "),
|
Self::Webseeds => torrent
|
||||||
Self::WebseedsSendingToUs => format_option_string(torrent.webseeds_sending_to_us),
|
.webseeds
|
||||||
|
.as_deref()
|
||||||
|
.map_or_else(String::new, |w| w.join(", ")),
|
||||||
|
Self::WebseedsSendingToUs => format_option(torrent.webseeds_sending_to_us),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,15 +287,15 @@ impl Wrapper for TorrentGetField {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_option_string<T: Display>(value: Option<T>) -> String {
|
fn format_option<T: Display>(value: Option<T>) -> String {
|
||||||
value.map(|v| v.to_string()).unwrap_or_default()
|
value.map_or_else(String::new, |v| v.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_eta(value: Option<i64>) -> String {
|
fn format_eta(value: Option<i64>) -> String {
|
||||||
match value {
|
match value {
|
||||||
Some(-2) => "?".into(),
|
Some(-2) => "?".into(),
|
||||||
None | Some(-1 | ..0) => String::new(),
|
Some(v) if v > 0 => format!("{v} s"),
|
||||||
Some(v) => format!("{v} s"),
|
_ => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,15 +305,14 @@ trait Formatter {
|
|||||||
|
|
||||||
impl Formatter for Option<f32> {
|
impl Formatter for Option<f32> {
|
||||||
fn format(&self) -> String {
|
fn format(&self) -> String {
|
||||||
self.map(|v| format!("{v:.2}")).unwrap_or_default()
|
self.map_or_else(String::new, |v| format!("{v:.2}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> Formatter for Option<Vec<T>> {
|
impl<T> Formatter for Option<Vec<T>> {
|
||||||
fn format(&self) -> String {
|
fn format(&self) -> String {
|
||||||
self.as_ref()
|
self.as_ref()
|
||||||
.map(|v| v.len().to_string())
|
.map_or_else(String::new, |v| v.len().to_string())
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
81
src/event.rs
81
src/event.rs
@ -1,8 +1,10 @@
|
|||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent};
|
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent};
|
||||||
use std::sync::mpsc;
|
use std::{
|
||||||
use std::thread;
|
sync::mpsc,
|
||||||
use std::time::{Duration, Instant};
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
/// Terminal events.
|
/// Terminal events.
|
||||||
@ -19,14 +21,10 @@ pub enum Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Terminal event handler.
|
/// Terminal event handler.
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct EventHandler {
|
pub struct EventHandler {
|
||||||
/// Event sender channel.
|
|
||||||
sender: mpsc::Sender<Event>,
|
|
||||||
/// Event receiver channel.
|
|
||||||
receiver: mpsc::Receiver<Event>,
|
receiver: mpsc::Receiver<Event>,
|
||||||
/// Event handler thread.
|
#[allow(dead_code)]
|
||||||
handler: thread::JoinHandle<()>,
|
handler: thread::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,46 +33,43 @@ impl EventHandler {
|
|||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// TODO: add panic
|
/// Panics if event polling or sending fails.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(tick_rate: u64) -> Self {
|
pub fn new(tick_rate_ms: u64) -> Self {
|
||||||
let tick_rate = Duration::from_millis(tick_rate);
|
let tick_rate = Duration::from_millis(tick_rate_ms);
|
||||||
let (sender, receiver) = mpsc::channel();
|
let (sender, receiver) = mpsc::channel();
|
||||||
let handler = {
|
|
||||||
let sender = sender.clone();
|
|
||||||
thread::spawn(move || {
|
|
||||||
let mut last_tick = Instant::now();
|
|
||||||
loop {
|
|
||||||
let timeout = tick_rate
|
|
||||||
.checked_sub(last_tick.elapsed())
|
|
||||||
.unwrap_or(tick_rate);
|
|
||||||
|
|
||||||
if event::poll(timeout).expect("no events available") {
|
let handler = thread::spawn(move || {
|
||||||
match event::read() {
|
let mut last_tick = Instant::now();
|
||||||
Ok(CrosstermEvent::Key(e)) => sender.send(Event::Key(e)),
|
loop {
|
||||||
Ok(CrosstermEvent::Mouse(e)) => sender.send(Event::Mouse(e)),
|
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
|
||||||
Ok(CrosstermEvent::Resize(w, h)) => sender.send(Event::Resize(w, h)),
|
|
||||||
Err(e) => {
|
if event::poll(timeout).expect("event polling failed") {
|
||||||
error!("Error reading event: {:?}", e);
|
let send_result = match event::read() {
|
||||||
break;
|
Ok(CrosstermEvent::Key(e)) => sender.send(Event::Key(e)),
|
||||||
}
|
Ok(CrosstermEvent::Mouse(e)) => sender.send(Event::Mouse(e)),
|
||||||
_ => Ok(()), // Ignore other events
|
Ok(CrosstermEvent::Resize(w, h)) => sender.send(Event::Resize(w, h)),
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error reading event: {e:?}");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
.expect("failed to send terminal event");
|
};
|
||||||
}
|
if send_result.is_err() {
|
||||||
|
break;
|
||||||
if last_tick.elapsed() >= tick_rate {
|
|
||||||
sender.send(Event::Tick).expect("failed to send tick event");
|
|
||||||
last_tick = Instant::now();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
};
|
if last_tick.elapsed() >= tick_rate {
|
||||||
Self {
|
if sender.send(Event::Tick).is_err() {
|
||||||
sender,
|
break;
|
||||||
receiver,
|
}
|
||||||
handler,
|
last_tick = Instant::now();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { receiver, handler }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receive the next event from the handler thread.
|
/// Receive the next event from the handler thread.
|
||||||
@ -84,7 +79,7 @@ impl EventHandler {
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// Returns an error if the sender is disconnected.
|
||||||
pub fn next(&self) -> Result<Event> {
|
pub fn next(&self) -> Result<Event> {
|
||||||
Ok(self.receiver.recv()?)
|
Ok(self.receiver.recv()?)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,7 +40,7 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Act
|
|||||||
|
|
||||||
let keybinds = &app.config.keybinds;
|
let keybinds = &app.config.keybinds;
|
||||||
|
|
||||||
let actions = [
|
Ok([
|
||||||
(Action::Quit, &keybinds.quit),
|
(Action::Quit, &keybinds.quit),
|
||||||
(Action::NextTab, &keybinds.next_tab),
|
(Action::NextTab, &keybinds.next_tab),
|
||||||
(Action::PrevTab, &keybinds.prev_tab),
|
(Action::PrevTab, &keybinds.prev_tab),
|
||||||
@ -56,14 +56,9 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Act
|
|||||||
(Action::Select, &keybinds.select),
|
(Action::Select, &keybinds.select),
|
||||||
(Action::ToggleHelp, &keybinds.toggle_help),
|
(Action::ToggleHelp, &keybinds.toggle_help),
|
||||||
(Action::Move, &keybinds.move_torrent),
|
(Action::Move, &keybinds.move_torrent),
|
||||||
];
|
]
|
||||||
|
.into_iter()
|
||||||
for (action, keybind) in actions {
|
.find_map(|(action, keybind)| matches_keybind(&key_event, keybind).then_some(action)))
|
||||||
if matches_keybind(&key_event, keybind) {
|
|
||||||
return Ok(Some(action));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the updates of [`App`].
|
/// Handles the updates of [`App`].
|
||||||
@ -101,14 +96,12 @@ pub async fn update(app: &mut App, action: Action) -> Result<()> {
|
|||||||
|
|
||||||
/// Check if a [`KeyEvent`] matches a configured keybind string
|
/// Check if a [`KeyEvent`] matches a configured keybind string
|
||||||
fn matches_keybind(event: &KeyEvent, config_key: &str) -> bool {
|
fn matches_keybind(event: &KeyEvent, config_key: &str) -> bool {
|
||||||
parse_keybind(config_key)
|
parse_keybind(config_key).is_ok_and(|parsed| parsed == *event)
|
||||||
.map(|parsed_ev| parsed_ev == *event)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ParseKeybingError {
|
pub enum ParseKeybindError {
|
||||||
/// No “main” key was found (e.g. the user only wrote modifiers).
|
/// No "main" key was found (e.g. the user only wrote modifiers).
|
||||||
#[error("no main key was found in input")]
|
#[error("no main key was found in input")]
|
||||||
NoKeyCode,
|
NoKeyCode,
|
||||||
/// An unrecognized token was encountered.
|
/// An unrecognized token was encountered.
|
||||||
@ -116,7 +109,7 @@ pub enum ParseKeybingError {
|
|||||||
UnknownPart(String),
|
UnknownPart(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybingError> {
|
fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybindError> {
|
||||||
let mut modifiers = KeyModifiers::NONE;
|
let mut modifiers = KeyModifiers::NONE;
|
||||||
let mut key_code = None;
|
let mut key_code = None;
|
||||||
|
|
||||||
@ -128,8 +121,8 @@ fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybingErr
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let low = part.to_lowercase();
|
|
||||||
match low.as_str() {
|
match part.to_ascii_lowercase().as_str() {
|
||||||
// modifiers
|
// modifiers
|
||||||
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
|
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
|
||||||
"shift" => modifiers |= KeyModifiers::SHIFT,
|
"shift" => modifiers |= KeyModifiers::SHIFT,
|
||||||
@ -168,27 +161,25 @@ fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybingErr
|
|||||||
"apostrophe" => key_code = Some(KeyCode::Char('\'')),
|
"apostrophe" => key_code = Some(KeyCode::Char('\'')),
|
||||||
|
|
||||||
// function keys F1...F<N>
|
// function keys F1...F<N>
|
||||||
f if f.starts_with('f') && f.len() > 1 => {
|
f if f.starts_with('f') => {
|
||||||
let num_str = &f[1..];
|
key_code = Some(KeyCode::F(
|
||||||
match num_str.parse::<u8>() {
|
f[1..]
|
||||||
Ok(n) => key_code = Some(KeyCode::F(n)),
|
.parse()
|
||||||
Err(_) => return Err(ParseKeybingError::UnknownPart(part.to_owned())),
|
.map_err(|_| ParseKeybindError::UnknownPart(part.to_owned()))?,
|
||||||
}
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// single‐character fallback
|
// single-character fallback
|
||||||
_ if part.len() == 1 => {
|
_ if part.len() == 1 => {
|
||||||
if let Some(ch) = part.chars().next() {
|
key_code = part.chars().next().map(KeyCode::Char);
|
||||||
key_code = Some(KeyCode::Char(ch));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// unknown token
|
// unknown token
|
||||||
other => return Err(ParseKeybingError::UnknownPart(other.to_owned())),
|
other => return Err(ParseKeybindError::UnknownPart(other.to_owned())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
key_code
|
key_code
|
||||||
.map(|kc| KeyEvent::new(kc, modifiers))
|
.map(|kc| KeyEvent::new(kc, modifiers))
|
||||||
.ok_or(ParseKeybingError::NoKeyCode)
|
.ok_or(ParseKeybindError::NoKeyCode)
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/main.rs
38
src/main.rs
@ -3,7 +3,6 @@ use ratatui::{Terminal, backend::CrosstermBackend};
|
|||||||
use std::{io, sync::Arc};
|
use std::{io, sync::Arc};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::Mutex,
|
sync::Mutex,
|
||||||
task::JoinHandle,
|
|
||||||
time::{self, Duration},
|
time::{self, Duration},
|
||||||
};
|
};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
@ -26,8 +25,7 @@ async fn main() -> Result<()> {
|
|||||||
setup_logger(&config)?;
|
setup_logger(&config)?;
|
||||||
|
|
||||||
let app = Arc::new(Mutex::new(App::new(config)?));
|
let app = Arc::new(Mutex::new(App::new(config)?));
|
||||||
|
spawn_torrent_updater(Arc::clone(&app));
|
||||||
spawn_torrent_updater(app.clone());
|
|
||||||
|
|
||||||
let backend = CrosstermBackend::new(io::stderr());
|
let backend = CrosstermBackend::new(io::stderr());
|
||||||
let terminal = Terminal::new(backend)?;
|
let terminal = Terminal::new(backend)?;
|
||||||
@ -36,42 +34,32 @@ async fn main() -> Result<()> {
|
|||||||
tui.init()?;
|
tui.init()?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
{
|
let mut app_guard = app.lock().await;
|
||||||
let app_guard = app.lock().await;
|
if !app_guard.running {
|
||||||
if !app_guard.running {
|
break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
tui.draw(&mut app_guard)?;
|
||||||
|
drop(app_guard);
|
||||||
|
|
||||||
{
|
if let Event::Key(key_event) = tui.events.next()? {
|
||||||
let mut app_guard = app.lock().await;
|
let mut app_guard = app.lock().await;
|
||||||
tui.draw(&mut app_guard)?;
|
if let Some(action) = get_action(key_event, &mut app_guard).await? {
|
||||||
}
|
update(&mut app_guard, action).await?;
|
||||||
|
|
||||||
match tui.events.next()? {
|
|
||||||
Event::Key(key_event) => {
|
|
||||||
let mut app_guard = app.lock().await;
|
|
||||||
if let Some(action) = get_action(key_event, &mut app_guard).await? {
|
|
||||||
update(&mut app_guard, action).await?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Event::Mouse(_) | Event::Resize(_, _) | Event::Tick => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tui.exit()?;
|
tui.exit()
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_torrent_updater(app: Arc<Mutex<App>>) -> JoinHandle<()> {
|
fn spawn_torrent_updater(app: Arc<Mutex<App>>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = time::interval(Duration::from_secs(TORRENT_UPDATE_INTERVAL_SECS));
|
let mut interval = time::interval(Duration::from_secs(TORRENT_UPDATE_INTERVAL_SECS));
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
let mut app = app.lock().await;
|
if let Err(e) = app.lock().await.torrents.update().await {
|
||||||
if let Err(e) = app.torrents.update().await {
|
|
||||||
warn!("Failed to update torrents: {e}");
|
warn!("Failed to update torrents: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user