From 866ee98c79952ecc6c50a4f08b19ece4485bbd73 Mon Sep 17 00:00:00 2001 From: Ryan Aidan Date: Sun, 30 Jun 2024 23:16:50 +0800 Subject: [PATCH] feat: torrent tab status bar (#43) * feat: torrent tab status bar * chore: remove commented * fix: compiler warnings * feat: decrement selected torrent on delete * feat: add/delete torrent name * fix: warnings * feat: cancel status bar after 5s * fix: nits * fix: tick nits * fix: comments * fix: spacing --- rm-main/src/transmission/action.rs | 30 ++-- rm-main/src/ui/tabs/torrents/task_manager.rs | 82 ++++++++-- .../src/ui/tabs/torrents/tasks/add_magnet.rs | 8 +- .../ui/tabs/torrents/tasks/delete_torrent.rs | 21 ++- rm-main/src/ui/tabs/torrents/tasks/mod.rs | 1 + rm-main/src/ui/tabs/torrents/tasks/status.rs | 142 ++++++++++++++++++ rm-shared/src/action.rs | 4 + rm-shared/src/lib.rs | 1 + rm-shared/src/status_task.rs | 5 + 9 files changed, 262 insertions(+), 32 deletions(-) create mode 100644 rm-main/src/ui/tabs/torrents/tasks/status.rs create mode 100644 rm-shared/src/status_task.rs diff --git a/rm-main/src/transmission/action.rs b/rm-main/src/transmission/action.rs index 67bffa6..8266593 100644 --- a/rm-main/src/transmission/action.rs +++ b/rm-main/src/transmission/action.rs @@ -39,18 +39,22 @@ pub async fn action_handler(ctx: app::Ctx, mut trans_rx: UnboundedReceiver { + ctx.send_action(Action::TaskSuccess); + } + Err(e) => { + let error_title = "Failed to add a torrent"; + let msg = "Failed to add torrent with URL/Path:\n\"".to_owned() + + url + + "\"\n" + + &e.to_string(); + let error_message = ErrorMessage { + title: error_title.to_string(), + message: msg, + }; + ctx.send_action(Action::Error(Box::new(error_message))); + } } } TorrentAction::Stop(ids) => { @@ -76,6 +80,7 @@ pub async fn action_handler(ctx: app::Ctx, mut trans_rx: UnboundedReceiver { ctx.client @@ -84,6 +89,7 @@ pub async fn action_handler(ctx: app::Ctx, mut trans_rx: UnboundedReceiver { let new_torrent_info = ctx diff --git a/rm-main/src/ui/tabs/torrents/task_manager.rs b/rm-main/src/ui/tabs/torrents/task_manager.rs index 55b8d91..eab8171 100644 --- a/rm-main/src/ui/tabs/torrents/task_manager.rs +++ b/rm-main/src/ui/tabs/torrents/task_manager.rs @@ -1,16 +1,18 @@ use std::sync::{Arc, Mutex}; use ratatui::prelude::*; +use throbber_widgets_tui::ThrobberState; use crate::{app, ui::components::Component}; -use rm_shared::action::Action; +use rm_shared::{action::Action, status_task::StatusTask}; use super::{ tasks::{ add_magnet::AddMagnetBar, default::DefaultBar, - delete_torrent::{self, DeleteBar}, + delete_torrent::{self, DeleteBar, TorrentInfo}, filter::FilterBar, + status::{CurrentTaskState, StatusBar}, }, TableManager, }; @@ -31,11 +33,22 @@ impl TaskManager { } } -enum CurrentTask { +pub enum CurrentTask { AddMagnetBar(AddMagnetBar), DeleteBar(DeleteBar), FilterBar(FilterBar), Default(DefaultBar), + Status(StatusBar), +} + +impl CurrentTask { + fn tick(&mut self) -> Option { + if let Self::Status(status_bar) = self { + status_bar.tick() + } else { + None + } + } } impl Component for TaskManager { @@ -44,20 +57,46 @@ impl Component for TaskManager { use Action as A; match &mut self.current_task { CurrentTask::AddMagnetBar(magnet_bar) => match magnet_bar.handle_actions(action) { - Some(A::Quit) => self.finish_task(), + Some(A::TaskPending(task)) => self.pending_task(task), + Some(A::Quit) => self.cancel_task(), Some(A::Render) => Some(A::Render), _ => None, }, CurrentTask::DeleteBar(delete_bar) => match delete_bar.handle_actions(action) { - Some(A::Quit) => self.finish_task(), + Some(A::TaskPending(task)) => { + let selected = self + .table_manager + .lock() + .unwrap() + .table + .state + .borrow() + .selected(); + + // select closest existing torrent + if let Some(idx) = selected { + if idx > 0 { + self.table_manager.lock().unwrap().table.previous(); + } + } + self.pending_task(task) + } + Some(A::Quit) => self.cancel_task(), Some(A::Render) => Some(A::Render), _ => None, }, CurrentTask::FilterBar(filter_bar) => match filter_bar.handle_actions(action) { - Some(A::Quit) => self.finish_task(), + Some(A::Quit) => self.cancel_task(), + Some(A::Render) => Some(A::Render), + _ => None, + }, + + CurrentTask::Status(status_bar) => match status_bar.handle_actions(action) { + Some(A::Quit) => self.cancel_task(), Some(A::Render) => Some(A::Render), + Some(action) => self.handle_events_to_manager(&action), _ => None, }, @@ -71,8 +110,13 @@ impl Component for TaskManager { CurrentTask::DeleteBar(delete_bar) => delete_bar.render(f, rect), CurrentTask::FilterBar(filter_bar) => filter_bar.render(f, rect), CurrentTask::Default(default_bar) => default_bar.render(f, rect), + CurrentTask::Status(status_bar) => status_bar.render(f, rect), } } + + fn tick(&mut self) -> Option { + self.current_task.tick() + } } impl TaskManager { @@ -100,7 +144,10 @@ impl TaskManager { if let Some(torrent) = self.table_manager.lock().unwrap().current_torrent() { self.current_task = CurrentTask::DeleteBar(DeleteBar::new( self.ctx.clone(), - vec![torrent.id.clone()], + vec![TorrentInfo { + id: torrent.id.clone(), + name: torrent.torrent_name.clone(), + }], mode, )); Some(Action::SwitchToInputMode) @@ -109,12 +156,21 @@ impl TaskManager { } } - fn finish_task(&mut self) -> Option { - if !matches!(self.current_task, CurrentTask::Default(_)) { - self.current_task = CurrentTask::Default(DefaultBar::new(self.ctx.clone())); - Some(Action::SwitchToNormalMode) - } else { - None + fn pending_task(&mut self, task: StatusTask) -> Option { + if matches!(self.current_task, CurrentTask::Status(_)) { + return None; + } + let state = Arc::new(Mutex::new(ThrobberState::default())); + self.current_task = + CurrentTask::Status(StatusBar::new(task, CurrentTaskState::Loading(state))); + Some(Action::SwitchToNormalMode) + } + + fn cancel_task(&mut self) -> Option { + if matches!(self.current_task, CurrentTask::Default(_)) { + return None; } + self.current_task = CurrentTask::Default(DefaultBar::new(self.ctx.clone())); + Some(Action::SwitchToNormalMode) } } diff --git a/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs b/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs index bbffbe8..0688d75 100644 --- a/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs +++ b/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs @@ -6,7 +6,7 @@ use crate::{ transmission::TorrentAction, ui::{components::Component, tabs::torrents::input_manager::InputManager, to_input_request}, }; -use rm_shared::action::Action; +use rm_shared::{action::Action, status_task::StatusTask}; pub struct AddMagnetBar { input_magnet_mgr: InputManager, @@ -25,7 +25,7 @@ impl AddMagnetBar { Self { input_magnet_mgr: InputManager::new( ctx.clone(), - "Add (Magnet URL/ Torrent path): ".to_string(), + "Add (Magnet URL / Torrent path): ".to_string(), ), input_location_mgr: InputManager::new_with_value( ctx.clone(), @@ -66,7 +66,9 @@ impl AddMagnetBar { self.input_magnet_mgr.text(), Some(self.input_location_mgr.text()), )); - return Some(Action::Quit); + return Some(Action::TaskPending(StatusTask::Add( + self.input_magnet_mgr.text(), + ))); } if input.code == KeyCode::Esc { return Some(Action::Quit); diff --git a/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs b/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs index 0d5cb28..bbd4f1c 100644 --- a/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs +++ b/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs @@ -8,9 +8,16 @@ use crate::{ ui::{components::Component, tabs::torrents::input_manager::InputManager, to_input_request}, }; use rm_shared::action::Action; +use rm_shared::status_task::StatusTask; + +#[derive(Clone)] +pub struct TorrentInfo { + pub id: Id, + pub name: String, +} pub struct DeleteBar { - torrents_to_delete: Vec, + torrents_to_delete: Vec, ctx: app::Ctx, input_mgr: InputManager, mode: Mode, @@ -22,7 +29,7 @@ pub enum Mode { } impl DeleteBar { - pub fn new(ctx: app::Ctx, to_delete: Vec, mode: Mode) -> Self { + pub fn new(ctx: app::Ctx, to_delete: Vec, mode: Mode) -> Self { let prompt = { match mode { Mode::WithFiles => "Really delete selected WITH files? (y/n) ".to_string(), @@ -50,7 +57,11 @@ impl Component for DeleteBar { if input.code == KeyCode::Enter { let text = self.input_mgr.text().to_lowercase(); if text == "y" || text == "yes" { - let torrents_to_delete = self.torrents_to_delete.clone(); + let torrents_to_delete: Vec = self + .torrents_to_delete + .iter() + .map(|x| x.id.clone()) + .collect(); match self.mode { Mode::WithFiles => self.ctx.send_torrent_action( TorrentAction::DeleteWithFiles(torrents_to_delete), @@ -62,7 +73,9 @@ impl Component for DeleteBar { )) } } - return Some(Action::Quit); + return Some(Action::TaskPending(StatusTask::Delete( + self.torrents_to_delete[0].name.clone(), + ))); } else if text == "n" || text == "no" { return Some(Action::Quit); } diff --git a/rm-main/src/ui/tabs/torrents/tasks/mod.rs b/rm-main/src/ui/tabs/torrents/tasks/mod.rs index 269b3e5..9dbc1de 100644 --- a/rm-main/src/ui/tabs/torrents/tasks/mod.rs +++ b/rm-main/src/ui/tabs/torrents/tasks/mod.rs @@ -2,3 +2,4 @@ pub mod add_magnet; pub mod default; pub mod delete_torrent; pub mod filter; +pub mod status; diff --git a/rm-main/src/ui/tabs/torrents/tasks/status.rs b/rm-main/src/ui/tabs/torrents/tasks/status.rs new file mode 100644 index 0000000..efc9da3 --- /dev/null +++ b/rm-main/src/ui/tabs/torrents/tasks/status.rs @@ -0,0 +1,142 @@ +use std::sync::{Arc, Mutex}; + +use crate::ui::components::Component; + +use ratatui::{prelude::*, widgets::Paragraph}; +use rm_shared::{action::Action, status_task::StatusTask}; +use throbber_widgets_tui::ThrobberState; +use tokio::time::{self, Instant}; + +pub struct StatusBar { + task: StatusTask, + pub task_status: CurrentTaskState, +} + +impl StatusBar { + pub const fn new(task: StatusTask, task_status: CurrentTaskState) -> Self { + Self { task, task_status } + } +} + +fn format_display_name(name: &str) -> String { + if name.len() < 60 { + name.to_string() + } else { + let truncated = &name[0..59]; + format!("\"{truncated}...\"") + } +} + +impl Component for StatusBar { + fn render(&mut self, f: &mut Frame, rect: Rect) { + match &self.task_status { + CurrentTaskState::Loading(state) => { + let status_text = match &self.task { + StatusTask::Add(name) => { + let display_name = format_display_name(&name); + format!("Adding {display_name}") + } + StatusTask::Delete(name) => { + let display_name = format_display_name(&name); + format!("Deleting {display_name}") + } + }; + let default_throbber = throbber_widgets_tui::Throbber::default() + .label(status_text) + .style(ratatui::style::Style::default().fg(ratatui::style::Color::Yellow)); + f.render_stateful_widget( + default_throbber.clone(), + rect, + &mut state.lock().unwrap(), + ); + } + task_state => { + let status_text = match task_state { + CurrentTaskState::Failure => match &self.task { + StatusTask::Add(name) => { + let display_name = format_display_name(&name); + format!(" Error adding {display_name}") + } + StatusTask::Delete(name) => { + let display_name = format_display_name(&name); + format!(" Error deleting {display_name}") + } + }, + CurrentTaskState::Success(_) => match &self.task { + StatusTask::Add(name) => { + let display_name = format_display_name(&name); + format!(" Added {display_name}") + } + StatusTask::Delete(name) => { + let display_name = format_display_name(&name); + format!(" Deleted {display_name}") + } + }, + _ => return, + }; + let mut line = Line::default(); + match task_state { + CurrentTaskState::Failure => { + line.push_span(Span::styled("", Style::default().red())); + } + CurrentTaskState::Success(_) => { + line.push_span(Span::styled("", Style::default().green())); + } + _ => return, + } + line.push_span(Span::raw(status_text)); + let paragraph = Paragraph::new(line); + f.render_widget(paragraph, rect); + } + } + } + + fn handle_actions(&mut self, action: Action) -> Option { + match action { + Action::Tick => self.tick(), + Action::TaskSuccess => { + self.task_status.set_success(time::Instant::now()); + Some(Action::Render) + } + Action::Error(_) => { + self.task_status.set_failure(); + Some(Action::Render) + } + _ => Some(action), + } + } + + fn tick(&mut self) -> Option { + match &self.task_status { + CurrentTaskState::Loading(state) => { + state.lock().unwrap().calc_next(); + Some(Action::Render) + } + CurrentTaskState::Success(start) => { + let expiration_duration = time::Duration::from_secs(5); + if start.elapsed() >= expiration_duration { + return Some(Action::Quit); + } + None + } + _ => None, + } + } +} + +#[derive(Clone)] +pub enum CurrentTaskState { + Loading(Arc>), + Success(Instant), + Failure, +} + +impl CurrentTaskState { + fn set_failure(&mut self) { + *self = Self::Failure; + } + + fn set_success(&mut self, start: Instant) { + *self = Self::Success(start); + } +} diff --git a/rm-shared/src/action.rs b/rm-shared/src/action.rs index f06a9ae..867f135 100644 --- a/rm-shared/src/action.rs +++ b/rm-shared/src/action.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use crate::status_task::StatusTask; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { HardQuit, @@ -33,6 +35,8 @@ pub enum Action { ChangeTab(u8), Input(KeyEvent), Error(Box), + TaskPending(StatusTask), + TaskSuccess, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/rm-shared/src/lib.rs b/rm-shared/src/lib.rs index e9a6726..2136505 100644 --- a/rm-shared/src/lib.rs +++ b/rm-shared/src/lib.rs @@ -1 +1,2 @@ pub mod action; +pub mod status_task; diff --git a/rm-shared/src/status_task.rs b/rm-shared/src/status_task.rs new file mode 100644 index 0000000..c550e11 --- /dev/null +++ b/rm-shared/src/status_task.rs @@ -0,0 +1,5 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StatusTask { + Add(String), + Delete(String), +}