diff --git a/src/app.rs b/src/app.rs index 0e04c2b..cd450aa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,8 +17,9 @@ use tokio::{sync::mpsc, task::AbortHandle}; #[cfg(feature = "captcha")] use crate::widget::captcha::CaptchaPopup; + use crate::{ - client::{Client, DownloadResult}, + client::{Client, DownloadClientResult, SingleDownloadResult}, clip::ClipboardManager, config::{Config, ConfigManager}, results::Results, @@ -34,7 +35,7 @@ use crate::{ clients::ClientsPopup, filter::FilterPopup, help::HelpPopup, - notifications::NotificationWidget, + notifications::{Notification, NotificationWidget}, page::PagePopup, results::ResultsWidget, search::SearchWidget, @@ -172,8 +173,8 @@ pub struct Context { pub last_key: String, pub results: Results, pub deltatime: f64, - errors: Vec, - notifications: Vec, + //errors: Vec, + notifications: Vec, failed_config_load: bool, should_quit: bool, should_dismiss_notifications: bool, @@ -182,12 +183,24 @@ pub struct Context { } impl Context { - pub fn show_error(&mut self, error: S) { - self.errors.push(error.to_string()); + pub fn notify_error(&mut self, msg: S) { + self.notify(Notification::error(msg)); + } + + pub fn notify_info(&mut self, msg: S) { + self.notify(Notification::info(msg)); + } + + pub fn notify_warn(&mut self, msg: S) { + self.notify(Notification::warning(msg)); + } + + pub fn notify_success(&mut self, msg: S) { + self.notify(Notification::success(msg)); } - pub fn notify(&mut self, msg: S) { - self.notifications.push(msg.to_string()); + pub fn notify(&mut self, notif: Notification) { + self.notifications.push(notif); } pub fn dismiss_notifications(&mut self) { @@ -214,7 +227,6 @@ impl Default for Context { src_info: NyaaHtmlSource::info(), theme: Theme::default(), config: Config::default(), - errors: Vec::new(), notifications: Vec::new(), page: 1, user: None, @@ -248,7 +260,7 @@ impl App { let (tx_res, mut rx_res) = mpsc::channel::>>(32); let (tx_evt, mut rx_evt) = mpsc::channel::(100); - let (tx_dl, mut rx_dl) = mpsc::channel::(100); + let (tx_dl, mut rx_dl) = mpsc::channel::(100); let (tx_cfg, mut rx_cfg) = mpsc::channel::(1); tokio::task::spawn(sync.clone().read_event_loop(tx_evt)); @@ -258,17 +270,17 @@ impl App { Ok(config) => { ctx.failed_config_load = false; if let Err(e) = config.full_apply(config_manager.path(), ctx, &mut self.widgets) { - ctx.show_error(e); + ctx.notify_error(e); } } Err(e) => { - ctx.show_error(format!("Failed to load config:\n{}", e)); + ctx.notify_error(format!("Failed to load config:\n{}", e)); if let Err(e) = ctx.config .clone() .full_apply(config_manager.path(), ctx, &mut self.widgets) { - ctx.show_error(e); + ctx.notify_error(e); } } } @@ -287,13 +299,13 @@ impl App { ClipboardManager::new(ctx.config.clipboard.clone().unwrap_or_default()) }; if let Some(err) = err { - ctx.show_error(err); + ctx.notify_error(err); } while !ctx.should_quit { if ctx.should_save_config && ctx.config.save_config_on_change { if let Err(e) = config_manager.store(&ctx.config) { - ctx.show_error(e); + ctx.notify_error(e); } ctx.should_save_config = false; } @@ -301,19 +313,19 @@ impl App { ctx.notifications .clone() .into_iter() - .for_each(|n| self.widgets.notification.add_notification(n)); + .for_each(|n| self.widgets.notification.add(n)); ctx.notifications.clear(); } - if !ctx.errors.is_empty() { - if TEST { - return Err(ctx.errors.join("\n\n").into()); - } - ctx.errors - .clone() - .into_iter() - .for_each(|n| self.widgets.notification.add_error(n)); - ctx.errors.clear(); - } + //if !ctx.errors.is_empty() { + // if TEST { + // return Err(ctx.errors.join("\n\n").into()); + // } + // ctx.errors + // .clone() + // .into_iter() + // .for_each(|n| self.widgets.notification.add(Notification::Error, n)); + // ctx.errors.clear(); + //} if ctx.should_dismiss_notifications { self.widgets.notification.dismiss_all(); ctx.should_dismiss_notifications = false; @@ -343,7 +355,7 @@ impl App { client_rqclient.clone(), ctx.client, )); - ctx.notify(format!("Downloading torrent with {}", ctx.client)); + ctx.notify_info(format!("Downloading torrent with {}", ctx.client)); } continue; } @@ -356,7 +368,7 @@ impl App { client_rqclient.clone(), ctx.client, )); - ctx.notify(format!( + ctx.notify_info(format!( "Downloading {} torrents with {}", ctx.batch.len(), ctx.client @@ -440,7 +452,7 @@ impl App { Err(e) => { // Clear results on error ctx.results = Results::default(); - ctx.show_error(e); + ctx.notify_error(e); }, } ctx.load_type = None; @@ -448,19 +460,39 @@ impl App { break; }, Some(dl) = rx_dl.recv() => { - if dl.batch { - for id in dl.success_ids.iter() { - ctx.batch.retain(|i| i.id.ne(id)); + //if dl.batch { + // for id in dl.success_ids.iter() { + // ctx.batch.retain(|i| i.id.ne(id)); + // } + //} + //if !dl.success_ids.is_empty() { + // if let Some(notif) = dl.success_msg { + // if let Some(notif_type) = dl.success_msg { + // ctx.notify(notif_type, notif); + // } + // } + //} + //for e in dl.errors.iter() { + // ctx.notify_error(e) + //} + match dl { + DownloadClientResult::Single(sr) => { + match sr { + SingleDownloadResult::Success(suc) => { + ctx.notify(suc.msg); + }, + SingleDownloadResult::Error(err) => { + ctx.notify(err.msg); + }, + }; } - } - if !dl.success_ids.is_empty() { - if let Some(notif) = dl.success_msg { - ctx.notify(notif); + DownloadClientResult::Batch(br) => { + if !br.ids.is_empty() { + ctx.notify(br.msg); + } + br.errors.into_iter().for_each(|e| ctx.notify(e)); } } - for e in dl.errors.iter() { - ctx.show_error(e) - } break; } Some(notif) = rx_cfg.recv() => { @@ -473,16 +505,16 @@ impl App { match config_manager.load() { Ok(config) => { match config.partial_apply(ctx, &mut self.widgets) { - Ok(()) => ctx.notify("Reloaded config".to_owned()), - Err(e) => ctx.show_error(e), + Ok(()) => ctx.notify_info("Reloaded config".to_owned()), + Err(e) => ctx.notify_error(e), } } - Err(e) => ctx.show_error(e), + Err(e) => ctx.notify_error(e), } }, ReloadType::Theme(t) => match theme::load_user_themes(ctx, config_manager.path()) { - Ok(()) => ctx.notify(format!("Reloaded theme \"{t}\"")), - Err(e) => ctx.show_error(e) + Ok(()) => ctx.notify_info(format!("Reloaded theme \"{t}\"")), + Err(e) => ctx.notify_error(e) }, } @@ -549,7 +581,7 @@ impl App { #[cfg(unix)] if let (KeyCode::Char('z'), &KeyModifiers::CONTROL) = (code, modifiers) { if let Err(e) = term::suspend_self(terminal) { - ctx.show_error(format!("Failed to suspend:\n{}", e)); + ctx.notify_error(format!("Failed to suspend:\n{}", e)); } // If we fail to continue the process, panic if let Err(e) = term::continue_self(terminal) { @@ -638,18 +670,20 @@ impl App { 'p' => item.post_link, 'i' => match item.extra.get("imdb").cloned() { Some(imdb) => imdb, - None => return ctx.show_error("No imdb ID found for this item."), + None => return ctx.notify_error("No imdb ID found for this item."), }, 'n' => item.title, _ => return, }; match clipboard.try_copy(&link) { - Ok(()) => ctx.notify(format!("Copied \"{}\" to clipboard", link)), - Err(e) => ctx.show_error(e), + Ok(()) => { + ctx.notify_success(format!("Copied \"{}\" to clipboard", link)) + } + Err(e) => ctx.notify_error(e), } } None if ['t', 'm', 'p', 'i', 'n'].contains(&c) => { - ctx.show_error("Failed to copy:\nFailed to get item") + ctx.notify_error("Failed to copy:\nFailed to get item") } None => {} } diff --git a/src/client.rs b/src/client.rs index 9ce1612..2de3654 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use strum::{Display, VariantArray}; use tokio::task::JoinSet; -use crate::{client::cmd::CmdClient, source::Item}; +use crate::{client::cmd::CmdClient, source::Item, widget::notifications::Notification}; use self::{ cmd::CmdConfig, @@ -24,17 +24,30 @@ pub mod transmission; pub struct DownloadError(String); +impl From for DownloadError { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From<&str> for DownloadError { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } +} + pub trait DownloadClient { fn download( item: Item, conf: ClientConfig, client: reqwest::Client, - ) -> impl std::future::Future + std::marker::Send + 'static; + ) -> impl std::future::Future + std::marker::Send + 'static; fn batch_download( items: Vec, conf: ClientConfig, client: reqwest::Client, - ) -> impl std::future::Future + std::marker::Send + 'static; + ) -> impl std::future::Future + std::marker::Send + 'static; + fn load_config(cfg: &mut ClientConfig); } impl Display for DownloadError { @@ -43,35 +56,51 @@ impl Display for DownloadError { } } -pub struct DownloadResult { - pub success_msg: Option, - pub success_ids: Vec, - pub batch: bool, - pub errors: Vec, +pub struct DownloadSuccessResult { + pub msg: Notification, + pub id: String, } -impl DownloadResult { - pub fn new>>( - success_msg: S, - success_ids: Vec, - errors: Vec, - batch: bool, - ) -> Self { - DownloadResult { - success_msg: success_msg.into(), - success_ids, - batch, - errors, - } +pub struct DownloadErrorResult { + pub msg: Notification, +} + +pub enum SingleDownloadResult { + Success(DownloadSuccessResult), + Error(DownloadErrorResult), +} + +pub struct BatchDownloadResult { + pub msg: Notification, + pub errors: Vec, + pub ids: Vec, +} + +pub enum DownloadClientResult { + Single(SingleDownloadResult), + Batch(BatchDownloadResult), +} + +impl SingleDownloadResult { + pub fn success(msg: S, id: String) -> Self { + Self::Success(DownloadSuccessResult { + msg: Notification::success(msg), + id, + }) } - pub fn error(error: DownloadError) -> Self { - DownloadResult { - success_msg: None, - success_ids: vec![], - batch: false, - errors: vec![error], - } + pub fn error(msg: S) -> Self { + Self::Error(DownloadErrorResult { + msg: Notification::error(msg), + }) + } + + pub fn is_success(&self) -> bool { + matches!(self, Self::Success(_)) + } + + pub fn is_error(&self) -> bool { + matches!(self, Self::Error(_)) } } @@ -123,7 +152,7 @@ pub async fn multidownload( items: &[Item], conf: &ClientConfig, client: &reqwest::Client, -) -> DownloadResult +) -> BatchDownloadResult where F: Fn(usize) -> String, { @@ -132,62 +161,37 @@ where let item = item.to_owned(); set.spawn(C::download(item.clone(), conf.clone(), client.clone())); } - let mut results: Vec = vec![]; + + let mut success_ids: Vec = vec![]; + let mut errors: Vec = vec![]; while let Some(res) = set.join_next().await { - let res = match res { - Ok(res) => res, - Err(e) => { - results.push(DownloadResult::error(DownloadError(e.to_string()))); - continue; - } - }; - results.push(res); + match res.unwrap_or_else(|e| SingleDownloadResult::error(e)) { + SingleDownloadResult::Success(sr) => success_ids.push(sr.id), + SingleDownloadResult::Error(er) => errors.push(er.msg), + } } - let (success, failure): (Vec, Vec) = - results.into_iter().partition(|d| d.errors.is_empty()); - let success_ids = success.into_iter().fold(vec![], |acc, s| { - acc.into_iter().chain(s.success_ids).collect() - }); - let errors = failure - .into_iter() - .fold(vec![], |acc, s| acc.into_iter().chain(s.errors).collect()); - - DownloadResult::new(success_msg(success_ids.len()), success_ids, errors, true) + BatchDownloadResult { + msg: Notification::success(success_msg(success_ids.len())), + errors, + ids: success_ids, + } } impl Client { - // pub async fn download(&self, item: Item, ctx: &mut Context) { - // let conf = ctx.config.client.to_owned(); - // let timeout = ctx.config.timeout; - // let item = item.clone(); - // let result = match self { - // Self::Cmd => cmd::download(item, conf).await, - // Self::Qbit => qbit::download(item, conf, timeout).await, - // Self::Transmission => transmission::download(item, conf, timeout).await, - // Self::Rqbit => rqbit::download(item, conf, timeout).await, - // Self::DefaultApp => default_app::download(item, conf).await, - // Self::Download => download::download(item, conf, timeout).await, - // }; - // match result { - // Ok(o) => ctx.notify(o), - // Err(e) => ctx.show_error(e), - // } - // } - pub async fn download( self, item: Item, conf: ClientConfig, client: reqwest::Client, - ) -> DownloadResult { + ) -> SingleDownloadResult { match self { Self::Cmd => CmdClient::download(item, conf, client).await, - Self::Qbit => QbitClient::download(item, conf, client).await, - Self::Transmission => TransmissionClient::download(item, conf, client).await, - Self::Rqbit => RqbitClient::download(item, conf, client).await, Self::DefaultApp => DefaultAppClient::download(item, conf, client).await, Self::Download => DownloadFileClient::download(item, conf, client).await, + Self::Qbit => QbitClient::download(item, conf, client).await, + Self::Rqbit => RqbitClient::download(item, conf, client).await, + Self::Transmission => TransmissionClient::download(item, conf, client).await, } } @@ -196,74 +200,25 @@ impl Client { items: Vec, conf: ClientConfig, client: reqwest::Client, - ) -> DownloadResult { + ) -> BatchDownloadResult { match self { - Client::Cmd => CmdClient::batch_download(items, conf, client).await, - Client::DefaultApp => DefaultAppClient::batch_download(items, conf, client).await, - Client::Download => DownloadFileClient::batch_download(items, conf, client).await, - Client::Rqbit => RqbitClient::batch_download(items, conf, client).await, - Client::Qbit => QbitClient::batch_download(items, conf, client).await, - Client::Transmission => TransmissionClient::batch_download(items, conf, client).await, + Self::Cmd => CmdClient::batch_download(items, conf, client).await, + Self::DefaultApp => DefaultAppClient::batch_download(items, conf, client).await, + Self::Download => DownloadFileClient::batch_download(items, conf, client).await, + Self::Qbit => QbitClient::batch_download(items, conf, client).await, + Self::Rqbit => RqbitClient::batch_download(items, conf, client).await, + Self::Transmission => TransmissionClient::batch_download(items, conf, client).await, } - // let conf = ctx.config.client.to_owned(); - // let timeout = ctx.config.timeout; - - // if let Some(res) = self - // .try_batch_download(items.to_owned(), conf.to_owned(), timeout) - // .await - // { - // return match res { - // Ok(o) => items - // .iter() - // .map(|i| DownloadResult::Success(i.title.to_owned(), o)) - // .collect(), - // Err(e) => items - // .iter() - // .map(|i| DownloadResult::Failure(i.title.to_owned(), DownloadError(e.clone()))) - // .collect(), - // }; - // } - // - // let mut set = JoinSet::new(); - // for item in items.iter() { - // let item = item.to_owned(); - // set.spawn(self.download_async(item.to_owned(), conf.to_owned(), timeout)); - // } - // let mut success_ids = vec![]; - // while let Some(res) = set.join_next().await { - // let res = match res { - // Ok(res) => res, - // Err(e) => { - // // ctx.show_error(format!("Failed to join download thread:\n{}", e)); - // continue; - // } - // }; - // match res { - // Ok(o) => { - // success_ids.push(o); - // } - // Err(e) => { - // // ctx.show_error(e); - // } - // } - // } - // vec![] - // ctx.notify(format!( - // "Successfully downloaded {} torrents with {}", - // success_ids.len(), - // self, - // )); - // ctx.batch.retain(|i| !success_ids.contains(&i.id)); // Remove successes from batch } pub fn load_config(self, cfg: &mut ClientConfig) { match self { - Self::Cmd => cmd::load_config(cfg), - Self::Qbit => qbit::load_config(cfg), - Self::Transmission => transmission::load_config(cfg), - Self::Rqbit => rqbit::load_config(cfg), - Self::DefaultApp => default_app::load_config(cfg), - Self::Download => download::load_config(cfg), + Self::Cmd => CmdClient::load_config(cfg), + Self::DefaultApp => DefaultAppClient::load_config(cfg), + Self::Download => DownloadFileClient::load_config(cfg), + Self::Rqbit => RqbitClient::load_config(cfg), + Self::Qbit => QbitClient::load_config(cfg), + Self::Transmission => TransmissionClient::load_config(cfg), }; } } diff --git a/src/client/cmd.rs b/src/client/cmd.rs index 03962ee..49192d9 100644 --- a/src/client/cmd.rs +++ b/src/client/cmd.rs @@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize}; use crate::{source::Item, util::cmd::CommandBuilder}; -use super::{multidownload, ClientConfig, DownloadClient, DownloadError, DownloadResult}; +use super::{ + multidownload, BatchDownloadResult, ClientConfig, DownloadClient, SingleDownloadResult, +}; #[derive(Serialize, Deserialize, Clone)] #[serde(default)] @@ -26,18 +28,12 @@ impl Default for CmdConfig { } } -pub fn load_config(cfg: &mut ClientConfig) { - if cfg.cmd.is_none() { - cfg.cmd = Some(CmdConfig::default()); - } -} - impl DownloadClient for CmdClient { - async fn download(item: Item, conf: ClientConfig, _: reqwest::Client) -> DownloadResult { + async fn download(item: Item, conf: ClientConfig, _: reqwest::Client) -> SingleDownloadResult { let cmd = match conf.cmd.to_owned() { Some(c) => c, None => { - return DownloadResult::error(DownloadError("Failed to get cmd config".to_owned())); + return SingleDownloadResult::error("Failed to get cmd config"); } }; let res = CommandBuilder::new(cmd.cmd) @@ -46,25 +42,19 @@ impl DownloadClient for CmdClient { .sub("{title}", &item.title) .sub("{file}", &item.file_name) .run(cmd.shell_cmd) - .map_err(|e| DownloadError(e.to_string())); + .map_err(|e| e.to_string()); - let (success_ids, errors) = match res { - Ok(()) => (vec![item.id], vec![]), - Err(e) => (vec![], vec![DownloadError(e.to_string())]), - }; - DownloadResult::new( - "Successfully ran command".to_owned(), - success_ids, - errors, - false, - ) + match res { + Ok(()) => SingleDownloadResult::success("Successfully ran command", item.id), + Err(e) => SingleDownloadResult::error(e), + } } async fn batch_download( items: Vec, conf: ClientConfig, client: reqwest::Client, - ) -> DownloadResult { + ) -> BatchDownloadResult { multidownload::( |s| format!("Successfully ran command on {} torrents", s), &items, @@ -73,4 +63,10 @@ impl DownloadClient for CmdClient { ) .await } + + fn load_config(cfg: &mut ClientConfig) { + if cfg.cmd.is_none() { + cfg.cmd = Some(CmdConfig::default()); + } + } } diff --git a/src/client/default_app.rs b/src/client/default_app.rs index 3258d04..8ad6ff2 100644 --- a/src/client/default_app.rs +++ b/src/client/default_app.rs @@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize}; use crate::source::Item; -use super::{multidownload, ClientConfig, DownloadClient, DownloadError, DownloadResult}; +use super::{ + multidownload, BatchDownloadResult, ClientConfig, DownloadClient, SingleDownloadResult, +}; #[derive(Serialize, Deserialize, Clone, Default)] #[serde(default)] @@ -12,45 +14,31 @@ pub struct DefaultAppConfig { pub struct DefaultAppClient; -pub fn load_config(cfg: &mut ClientConfig) { - if cfg.default_app.is_none() { - let def = DefaultAppConfig::default(); - cfg.default_app = Some(def); - } -} - impl DownloadClient for DefaultAppClient { - async fn download(item: Item, conf: ClientConfig, _: reqwest::Client) -> DownloadResult { + async fn download(item: Item, conf: ClientConfig, _: reqwest::Client) -> SingleDownloadResult { let conf = match conf.default_app.to_owned() { Some(c) => c, None => { - return DownloadResult::error(DownloadError( - "Failed to get default app config".to_owned(), - )); + return SingleDownloadResult::error("Failed to get default app config"); } }; let link = match conf.use_magnet { true => item.magnet_link.to_owned(), false => item.torrent_link.to_owned(), }; - let (success_ids, errors) = - match open::that_detached(link).map_err(|e| DownloadError(e.to_string())) { - Ok(()) => (vec![item.id], vec![]), - Err(e) => (vec![], vec![DownloadError(e.to_string())]), - }; - DownloadResult::new( - "Successfully opened link in default app".to_owned(), - success_ids, - errors, - false, - ) + match open::that_detached(link).map_err(|e| e.to_string()) { + Ok(()) => { + SingleDownloadResult::success("Successfully opened link in default app", item.id) + } + Err(e) => SingleDownloadResult::error(e), + } } async fn batch_download( items: Vec, conf: ClientConfig, client: reqwest::Client, - ) -> DownloadResult { + ) -> BatchDownloadResult { multidownload::( |s| format!("Successfully opened {} links in default app", s), &items, @@ -59,4 +47,11 @@ impl DownloadClient for DefaultAppClient { ) .await } + + fn load_config(cfg: &mut ClientConfig) { + if cfg.default_app.is_none() { + let def = DefaultAppConfig::default(); + cfg.default_app = Some(def); + } + } } diff --git a/src/client/download.rs b/src/client/download.rs index 22acf07..96f6040 100644 --- a/src/client/download.rs +++ b/src/client/download.rs @@ -5,7 +5,9 @@ use serde::{Deserialize, Serialize}; use crate::{source::Item, util::conv::get_hash}; -use super::{multidownload, ClientConfig, DownloadClient, DownloadError, DownloadResult}; +use super::{ + multidownload, BatchDownloadResult, ClientConfig, DownloadClient, SingleDownloadResult, +}; #[derive(Serialize, Deserialize, Clone)] #[serde(default)] @@ -36,12 +38,6 @@ impl Default for DownloadConfig { } } -pub fn load_config(cfg: &mut ClientConfig) { - if cfg.download.is_none() { - cfg.download = Some(DownloadConfig::default()); - } -} - async fn download_torrent( torrent_link: String, filename: String, @@ -74,17 +70,18 @@ async fn download_torrent( } impl DownloadClient for DownloadFileClient { - async fn download(item: Item, conf: ClientConfig, client: reqwest::Client) -> DownloadResult { + async fn download( + item: Item, + conf: ClientConfig, + client: reqwest::Client, + ) -> SingleDownloadResult { let conf = match conf.download.to_owned() { Some(c) => c, None => { - return DownloadResult::error(DownloadError( - "Failed to get download config".to_owned(), - )); + return SingleDownloadResult::error("Failed to get download config"); } }; - // TODO: Substitutions let filename = conf .filename .map(|f| { @@ -102,7 +99,7 @@ impl DownloadClient for DownloadFileClient { ) }) .unwrap_or(item.file_name.to_owned()); - let (success_msg, success_ids, errors) = match download_torrent( + match download_torrent( item.torrent_link.to_owned(), filename, conf.save_dir.clone(), @@ -112,32 +109,20 @@ impl DownloadClient for DownloadFileClient { ) .await { - Ok(path) => ( - Some(format!("Saved to \"{}\"", path)), - vec![item.id], - vec![], - ), - Err(e) => ( - None, - vec![], - vec![DownloadError( - format!( - "Failed to download torrent to {}:\n{}", - conf.save_dir.to_owned(), - e - ) - .to_owned(), - )], - ), - }; - DownloadResult::new(success_msg, success_ids, errors, false) + Ok(path) => SingleDownloadResult::success(format!("Saved to \"{}\"", path), item.id), + Err(e) => SingleDownloadResult::error(format!( + "Failed to download torrent to {}:\n{}", + conf.save_dir.to_owned(), + e + )), + } } async fn batch_download( items: Vec, conf: ClientConfig, client: reqwest::Client, - ) -> DownloadResult { + ) -> BatchDownloadResult { let save_dir = conf.download.clone().unwrap_or_default().save_dir.clone(); multidownload::( |s| format!("Saved {} torrents to folder {}", s, save_dir), @@ -147,4 +132,10 @@ impl DownloadClient for DownloadFileClient { ) .await } + + fn load_config(cfg: &mut ClientConfig) { + if cfg.download.is_none() { + cfg.download = Some(DownloadConfig::default()); + } + } } diff --git a/src/client/qbit.rs b/src/client/qbit.rs index b06b7cf..9c80e94 100644 --- a/src/client/qbit.rs +++ b/src/client/qbit.rs @@ -3,9 +3,9 @@ use std::{collections::HashMap, error::Error, fs}; use reqwest::{Response, StatusCode}; use serde::{Deserialize, Serialize}; -use crate::{source::Item, util::conv::add_protocol}; +use crate::{source::Item, util::conv::add_protocol, widget::notifications::Notification}; -use super::{ClientConfig, DownloadClient, DownloadError, DownloadResult}; +use super::{BatchDownloadResult, ClientConfig, DownloadClient, SingleDownloadResult}; #[derive(Serialize, Deserialize, Clone)] #[serde(default)] @@ -160,82 +160,98 @@ async fn add_torrent( Ok(client.post(url).form(&qbit.to_form(links)).send().await?) } -pub fn load_config(cfg: &mut ClientConfig) { - if cfg.qbit.is_none() { - cfg.qbit = Some(QbitConfig::default()); +async fn download_some( + items: Vec, + conf: ClientConfig, + client: reqwest::Client, +) -> Result<(), String> { + let Some(qbit) = conf.qbit.to_owned() else { + return Err("Failed to get qBittorrent config".to_owned()); + }; + if let Some(labels) = qbit.tags.clone() { + if let Some(bad) = labels.iter().find(|l| l.contains(',')) { + let bad = format!("\"{}\"", bad); + return Err(format!( + "qBittorrent tags must not contain commas:\n{}", + bad + )); + } } + if let Err(e) = login(&qbit, &client).await { + return Err(format!("Failed to get SID:\n{}", e)); + } + let links = match qbit.use_magnet.unwrap_or(true) { + true => items + .iter() + .map(|i| i.magnet_link.to_owned()) + .collect::>() + .join("\n"), + false => items + .iter() + .map(|i| i.torrent_link.to_owned()) + .collect::>() + .join("\n"), + }; + let res = match add_torrent(&qbit, links, &client).await { + Ok(res) => res, + Err(e) => return Err(format!("Failed to get response:\n{}", e)), + }; + if res.status() != StatusCode::OK { + let mut msg = format!( + "qBittorrent returned status code {} {}", + res.status().as_u16(), + res.status().canonical_reason().unwrap_or("") + ); + if res.status() == StatusCode::FORBIDDEN { + msg.push_str("\n\nLikely incorrect username/password"); + } + return Err(msg); + } + + let _ = logout(&qbit, &client).await; + Ok(()) } impl DownloadClient for QbitClient { - async fn download(item: Item, conf: ClientConfig, client: reqwest::Client) -> DownloadResult { - let mut res = Self::batch_download(vec![item], conf, client).await; - res.success_msg = Some("Successfully sent torrent to qBittorrent".to_string()); - res.batch = false; - res + async fn download( + item: Item, + conf: ClientConfig, + client: reqwest::Client, + ) -> SingleDownloadResult { + let id = item.id.clone(); + match download_some(vec![item], conf, client).await { + Ok(()) => SingleDownloadResult::success("Successfully sent torrent to qBittorrent", id), + Err(e) => SingleDownloadResult::error(e), + } } async fn batch_download( items: Vec, conf: ClientConfig, client: reqwest::Client, - ) -> DownloadResult { - // return DownloadResult::error(DownloadError("Failed to login :\\")); - let Some(qbit) = conf.qbit.to_owned() else { - return DownloadResult::error(DownloadError( - "Failed to get qBittorrent config".to_owned(), - )); - }; - if let Some(labels) = qbit.tags.clone() { - if let Some(bad) = labels.iter().find(|l| l.contains(',')) { - let bad = format!("\"{}\"", bad); - return DownloadResult::error(DownloadError( - format!("qBittorrent tags must not contain commas:\n{}", bad).to_owned(), - )); - } - } - if let Err(e) = login(&qbit, &client).await { - return DownloadResult::error(DownloadError(format!("Failed to get SID:\n{}", e))); + ) -> BatchDownloadResult { + let ids = items.iter().map(|i| i.id.clone()).collect(); + let num_items = items.len(); + match download_some(items, conf, client).await { + Ok(()) => BatchDownloadResult { + msg: Notification::success("Successfully sent {} torrents to qBittorrent"), + ids, + errors: vec![], + }, + Err(e) => BatchDownloadResult { + msg: Notification::error(format!( + "Failed to send {} torrents to qBittorrent", + num_items + )), + errors: vec![Notification::error(e)], + ids: vec![], + }, } - let links = match qbit.use_magnet.unwrap_or(true) { - true => items - .iter() - .map(|i| i.magnet_link.to_owned()) - .collect::>() - .join("\n"), - false => items - .iter() - .map(|i| i.torrent_link.to_owned()) - .collect::>() - .join("\n"), - }; - let res = match add_torrent(&qbit, links, &client).await { - Ok(res) => res, - Err(e) => { - return DownloadResult::error(DownloadError(format!( - "Failed to get response:\n{}", - e - ))) - } - }; - if res.status() != StatusCode::OK { - let mut msg = format!( - "qBittorrent returned status code {} {}", - res.status().as_u16(), - res.status().canonical_reason().unwrap_or("") - ); - if res.status() == StatusCode::FORBIDDEN { - msg.push_str("\n\nLikely incorrect username/password"); - } - return DownloadResult::error(DownloadError(msg)); - } - - let _ = logout(&qbit, &client).await; + } - DownloadResult::new( - format!("Successfully sent {} torrents to qBittorrent", items.len()), - items.into_iter().map(|i| i.id).collect(), - vec![], - true, - ) + fn load_config(cfg: &mut ClientConfig) { + if cfg.qbit.is_none() { + cfg.qbit = Some(QbitConfig::default()); + } } } diff --git a/src/client/rqbit.rs b/src/client/rqbit.rs index 9861fbd..8c7fdb0 100644 --- a/src/client/rqbit.rs +++ b/src/client/rqbit.rs @@ -6,7 +6,10 @@ use urlencoding::encode; use crate::{source::Item, util::conv::add_protocol}; -use super::{multidownload, ClientConfig, DownloadClient, DownloadError, DownloadResult}; +use super::{ + multidownload, BatchDownloadResult, ClientConfig, DownloadClient, DownloadError, + SingleDownloadResult, +}; #[derive(Serialize, Deserialize, Clone)] #[serde(default)] @@ -58,18 +61,16 @@ async fn add_torrent( } } -pub fn load_config(cfg: &mut ClientConfig) { - if cfg.rqbit.is_none() { - cfg.rqbit = Some(RqbitConfig::default()); - } -} - impl DownloadClient for RqbitClient { - async fn download(item: Item, conf: ClientConfig, client: reqwest::Client) -> DownloadResult { + async fn download( + item: Item, + conf: ClientConfig, + client: reqwest::Client, + ) -> SingleDownloadResult { let conf = match conf.rqbit.clone() { Some(q) => q, None => { - return DownloadResult::error(DownloadError("Failed to get rqbit config".into())); + return SingleDownloadResult::error("Failed to get rqbit config"); } }; let link = match conf.use_magnet.unwrap_or(true) { @@ -79,32 +80,27 @@ impl DownloadClient for RqbitClient { let res = match add_torrent(&conf, link, &client).await { Ok(r) => r, Err(e) => { - return DownloadResult::error(DownloadError(format!( + return SingleDownloadResult::error(DownloadError(format!( "Failed to get response from rqbit\n{}", e ))); } }; if res.status() != StatusCode::OK { - return DownloadResult::error(DownloadError(format!( + return SingleDownloadResult::error(DownloadError(format!( "rqbit returned status code {}", res.status().as_u16() ))); } - DownloadResult::new( - "Successfully sent torrent to rqbit".to_owned(), - vec![item.id], - vec![], - false, - ) + SingleDownloadResult::success("Successfully sent torrent to rqbit".to_owned(), item.id) } async fn batch_download( items: Vec, conf: ClientConfig, client: reqwest::Client, - ) -> DownloadResult { + ) -> BatchDownloadResult { multidownload::( |s| format!("Successfully sent {} torrents to rqbit", s), &items, @@ -113,4 +109,10 @@ impl DownloadClient for RqbitClient { ) .await } + + fn load_config(cfg: &mut ClientConfig) { + if cfg.rqbit.is_none() { + cfg.rqbit = Some(RqbitConfig::default()); + } + } } diff --git a/src/client/transmission.rs b/src/client/transmission.rs index 66cb55a..edced98 100644 --- a/src/client/transmission.rs +++ b/src/client/transmission.rs @@ -8,7 +8,9 @@ use transmission_rpc::{ use crate::{source::Item, util::conv::add_protocol}; -use super::{multidownload, ClientConfig, DownloadClient, DownloadError, DownloadResult}; +use super::{ + multidownload, BatchDownloadResult, ClientConfig, DownloadClient, SingleDownloadResult, +}; #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[repr(i8)] @@ -99,27 +101,23 @@ async fn add_torrent( Ok(()) } -pub fn load_config(cfg: &mut ClientConfig) { - if cfg.transmission.is_none() { - cfg.transmission = Some(TransmissionConfig::default()); - } -} - impl DownloadClient for TransmissionClient { - async fn download(item: Item, conf: ClientConfig, client: reqwest::Client) -> DownloadResult { + async fn download( + item: Item, + conf: ClientConfig, + client: reqwest::Client, + ) -> SingleDownloadResult { let Some(conf) = conf.transmission.clone() else { - return DownloadResult::error(DownloadError( - "Failed to get configuration for transmission".to_owned(), - )); + return SingleDownloadResult::error("Failed to get configuration for transmission"); }; if let Some(labels) = conf.labels.clone() { if let Some(bad) = labels.iter().find(|l| l.contains(',')) { let bad = format!("\"{}\"", bad); - return DownloadResult::error(DownloadError(format!( + return SingleDownloadResult::error(format!( "Transmission labels must not contain commas:\n{}", bad - ))); + )); } } @@ -128,21 +126,16 @@ impl DownloadClient for TransmissionClient { Some(false) => item.torrent_link.to_owned(), }; if let Err(e) = add_torrent(&conf, link, client).await { - return DownloadResult::error(DownloadError(e.to_string())); + return SingleDownloadResult::error(e); } - DownloadResult::new( - "Successfully sent torrent to Transmission".to_owned(), - vec![item.id], - vec![], - false, - ) + SingleDownloadResult::success("Successfully sent torrent to Transmission", item.id) } async fn batch_download( items: Vec, conf: ClientConfig, client: reqwest::Client, - ) -> DownloadResult { + ) -> BatchDownloadResult { multidownload::( |s| format!("Successfully sent {} torrents to Transmission", s), &items, @@ -151,4 +144,10 @@ impl DownloadClient for TransmissionClient { ) .await } + + fn load_config(cfg: &mut ClientConfig) { + if cfg.transmission.is_none() { + cfg.transmission = Some(TransmissionConfig::default()); + } + } } diff --git a/src/sync.rs b/src/sync.rs index 63da4ca..6385001 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -10,7 +10,7 @@ use tokio::sync::mpsc; use crate::{ app::LoadType, - client::{Client, ClientConfig, DownloadResult}, + client::{Client, ClientConfig, DownloadClientResult}, config::CONFIG_FILE, results::Results, source::{Item, SourceConfig, SourceResponse, SourceResults, Sources}, @@ -33,7 +33,7 @@ pub trait EventSync { ) -> impl std::future::Future + std::marker::Send + 'static; fn download( self, - tx_dl: mpsc::Sender, + tx_dl: mpsc::Sender, batch: bool, items: Vec, config: ClientConfig, @@ -118,7 +118,7 @@ impl EventSync for AppSync { async fn download( self, - tx_dl: mpsc::Sender, + tx_dl: mpsc::Sender, batch: bool, items: Vec, config: ClientConfig, @@ -126,8 +126,12 @@ impl EventSync for AppSync { client: Client, ) { let res = match batch { - true => client.batch_download(items, config, rq_client).await, - false => client.download(items[0].clone(), config, rq_client).await, + true => { + DownloadClientResult::Batch(client.batch_download(items, config, rq_client).await) + } + false => DownloadClientResult::Single( + client.download(items[0].clone(), config, rq_client).await, + ), }; let _ = tx_dl.send(res).await; } diff --git a/src/theme.rs b/src/theme.rs index ecf9435..215defd 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -34,10 +34,14 @@ pub struct Theme { pub solid_bg: Color, #[serde(with = "color_to_tui")] pub solid_fg: Color, - #[serde(with = "color_to_tui", alias = "trusted")] - pub success: Color, + #[serde(with = "color_to_tui", alias = "remake")] + pub info: Color, + #[serde(with = "color_to_tui", alias = "remake")] + pub warning: Color, #[serde(with = "color_to_tui", alias = "remake")] pub error: Color, + #[serde(with = "color_to_tui", alias = "trusted")] + pub success: Color, #[serde(default)] pub source: SourceTheme, @@ -62,14 +66,14 @@ pub fn load_user_themes(ctx: &mut Context, config_path: PathBuf) -> Result<(), S let f = match f { Ok(f) => f, Err(e) => { - ctx.show_error(format!("Failed to get theme file path :\n{}", e)); + ctx.notify_error(format!("Failed to get theme file path :\n{}", e)); return None; } }; let res = match Theme::from_path(f.path()) { Ok(t) => t, Err(e) => { - ctx.show_error(format!( + ctx.notify_error(format!( "Failed to parse theme \"{}\":\n{}", f.file_name().to_string_lossy(), e @@ -134,6 +138,8 @@ impl Default for Theme { hl_bg: Color::DarkGray, solid_bg: Color::White, solid_fg: Color::Black, + info: Color::LightCyan, + warning: Color::Yellow, success: Color::Green, error: Color::Red, source: Default::default(), @@ -160,6 +166,8 @@ pub fn default_themes() -> IndexMap { hl_bg: Color::Rgb(98, 114, 164), solid_fg: Color::Rgb(40, 42, 54), solid_bg: Color::Rgb(139, 233, 253), + info: Color::Rgb(189, 147, 249), + warning: Color::Rgb(241, 250, 140), success: Color::Rgb(80, 250, 123), error: Color::Rgb(255, 85, 85), source: Default::default(), @@ -174,6 +182,8 @@ pub fn default_themes() -> IndexMap { hl_bg: Color::Rgb(80, 73, 69), solid_bg: Color::Rgb(69, 133, 136), solid_fg: Color::Rgb(235, 219, 178), + info: Color::Rgb(214, 93, 14), + warning: Color::Rgb(250, 189, 47), success: Color::Rgb(152, 151, 26), error: Color::Rgb(204, 36, 29), source: Default::default(), @@ -188,6 +198,8 @@ pub fn default_themes() -> IndexMap { hl_bg: Color::Rgb(110, 115, 141), solid_bg: Color::Rgb(166, 218, 149), solid_fg: Color::Rgb(24, 25, 38), + info: Color::Rgb(125, 196, 228), + warning: Color::Rgb(238, 212, 159), success: Color::Rgb(166, 218, 149), error: Color::Rgb(237, 135, 150), source: Default::default(), diff --git a/src/widget.rs b/src/widget.rs index 53950e9..5c793d3 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -5,7 +5,6 @@ use ratatui::{ buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Stylize as _}, - text::Line, widgets::{ Block, Borders, Clear, Scrollbar, ScrollbarOrientation, ScrollbarState, TableState, Widget as _, @@ -55,27 +54,27 @@ pub enum Corner { } impl Corner { - pub fn try_title<'a, L: Into>>( - self, - text: L, - area: Rect, - hide_if_too_small: bool, - ) -> Option<(Line<'a>, Rect)> { - let line: Line = text.into(); - let line_width = min(area.width, line.width() as u16); - if hide_if_too_small && area.width < line.width() as u16 + 2 { - // Too small - return None; - } - let (left, y) = match self { - Corner::TopLeft => (area.left() + 1, area.top()), - Corner::TopRight => (area.right() - 1 - line_width, area.top()), - Corner::BottomLeft => (area.left() + 1, area.bottom() - 1), - Corner::BottomRight => (area.right() - 1 - line_width, area.bottom() - 1), - }; - let right = Rect::new(left, y, line_width, 1); - Some((line, right)) - } + //pub fn try_title<'a, L: Into>>( + // self, + // text: L, + // area: Rect, + // hide_if_too_small: bool, + //) -> Option<(Line<'a>, Rect)> { + // let line: Line = text.into(); + // let line_width = min(area.width, line.width() as u16); + // if hide_if_too_small && area.width < line.width() as u16 + 2 { + // // Too small + // return None; + // } + // let (left, y) = match self { + // Corner::TopLeft => (area.left() + 1, area.top()), + // Corner::TopRight => (area.right() - 1 - line_width, area.top()), + // Corner::BottomLeft => (area.left() + 1, area.bottom() - 1), + // Corner::BottomRight => (area.right() - 1 - line_width, area.bottom() - 1), + // }; + // let right = Rect::new(left, y, line_width, 1); + // Some((line, right)) + //} } pub fn scroll_padding( diff --git a/src/widget/batch.rs b/src/widget/batch.rs index 82959e0..0e2ef03 100644 --- a/src/widget/batch.rs +++ b/src/widget/batch.rs @@ -3,6 +3,7 @@ use human_bytes::human_bytes; use ratatui::{ layout::{Constraint, Margin, Rect}, style::{Style, Stylize}, + text::Line, widgets::{Clear, Row, ScrollbarOrientation, StatefulWidget, Table, Widget}, Frame, }; @@ -13,7 +14,7 @@ use crate::{ title, }; -use super::{border_block, Corner, VirtualStatefulTable}; +use super::{border_block, VirtualStatefulTable}; pub struct BatchWidget { table: VirtualStatefulTable, @@ -30,7 +31,13 @@ impl Default for BatchWidget { impl super::Widget for BatchWidget { fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { let buf = f.buffer_mut(); - let block = border_block(&ctx.theme, ctx.mode == Mode::Batch).title(title!("Batch")); + + let size = human_bytes(ctx.batch.iter().fold(0, |acc, i| acc + i.bytes) as f64); + let right_str = title!("Size({}): {}", ctx.batch.len(), size); + let block = border_block(&ctx.theme, ctx.mode == Mode::Batch) + .title(title!("Batch")) + .title_top(Line::from(right_str).right_aligned()); + let focus_color = match ctx.mode { Mode::Batch => ctx.theme.border_focused_color, _ => ctx.theme.border_color, @@ -94,12 +101,6 @@ impl super::Widget for BatchWidget { &mut self.table.scrollbar_state.content_length(rows.len()), ); } - - let size = human_bytes(ctx.batch.iter().fold(0, |acc, i| acc + i.bytes) as f64); - let right_str = title!("Size({}): {}", ctx.batch.len(), size); - if let Some((tr, area)) = Corner::TopRight.try_title(right_str, area, true) { - tr.render(area, buf); - } } fn handle_event(&mut self, ctx: &mut Context, evt: &Event) { diff --git a/src/widget/category.rs b/src/widget/category.rs index efe3d25..59715c0 100644 --- a/src/widget/category.rs +++ b/src/widget/category.rs @@ -164,7 +164,7 @@ impl Widget for CategoryPopup { if let Some(cat) = ctx.src_info.cats.get(self.major) { if let Some(item) = cat.entries.get(self.minor) { self.selected = item.id; - ctx.notify(format!("Category \"{}\"", item.name)); + ctx.notify_info(format!("Category \"{}\"", item.name)); } } ctx.mode = Mode::Loading(LoadType::Categorizing); diff --git a/src/widget/clients.rs b/src/widget/clients.rs index 6b1a5e6..262b783 100644 --- a/src/widget/clients.rs +++ b/src/widget/clients.rs @@ -73,8 +73,10 @@ impl Widget for ClientsPopup { c.load_config(&mut ctx.config.client); match ctx.save_config() { - Ok(_) => ctx.notify(format!("Updated download client to \"{}\"", c)), - Err(e) => ctx.show_error(format!("Failed to update config:\n{}", e)), + Ok(_) => { + ctx.notify_info(format!("Updated download client to \"{}\"", c)) + } + Err(e) => ctx.notify_error(format!("Failed to update config:\n{}", e)), } ctx.mode = Mode::Normal; } diff --git a/src/widget/filter.rs b/src/widget/filter.rs index ab4128d..d83285d 100644 --- a/src/widget/filter.rs +++ b/src/widget/filter.rs @@ -74,7 +74,7 @@ impl Widget for FilterPopup { self.selected = i; ctx.mode = Mode::Loading(LoadType::Filtering); if let Some(f) = ctx.src_info.filters.get(i) { - ctx.notify(format!("Filter by \"{}\"", f)); + ctx.notify_info(format!("Filter by \"{}\"", f)); } } } diff --git a/src/widget/notifications.rs b/src/widget/notifications.rs index cd62f08..ef37db9 100644 --- a/src/widget/notifications.rs +++ b/src/widget/notifications.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use crossterm::event::Event; use ratatui::{layout::Rect, Frame}; use serde::{Deserialize, Serialize}; @@ -8,6 +10,50 @@ use super::{notify_box::NotifyBox, Corner, Widget}; static MAX_NOTIFS: usize = 100; +#[derive(Clone, Copy, PartialEq)] +pub enum NotificationType { + Info, + Warning, + Error, + Success, +} + +#[derive(Clone)] +pub struct Notification { + pub content: String, + pub notif_type: NotificationType, +} + +impl Notification { + pub fn info(content: S) -> Self { + Self { + content: content.to_string(), + notif_type: NotificationType::Info, + } + } + + pub fn warning(content: S) -> Self { + Self { + content: content.to_string(), + notif_type: NotificationType::Warning, + } + } + + pub fn error(content: S) -> Self { + Self { + content: content.to_string(), + notif_type: NotificationType::Error, + } + } + + pub fn success(content: S) -> Self { + Self { + content: content.to_string(), + notif_type: NotificationType::Success, + } + } +} + #[derive(Clone, Copy, Serialize, Deserialize)] pub struct NotificationConfig { pub position: Option, @@ -48,31 +94,17 @@ impl NotificationWidget { !self.notifs.is_empty() } - pub fn add_notification(&mut self, notif: String) { - let new_notif = NotifyBox::new( + pub fn add(&mut self, notif: Notification) { + let persist = matches!(notif.notif_type, NotificationType::Error); + let notif = NotifyBox::new( notif, self.duration, self.position, self.animation_speed, self.max_width, - false, + persist, ); - self.add(new_notif); - } - - pub fn add_error(&mut self, error: String) { - let new_notif = NotifyBox::new( - error, - 0.0, - self.position, - self.animation_speed, - self.max_width, - true, - ); - self.add(new_notif); - } - fn add(&mut self, notif: NotifyBox) { self.notifs .iter_mut() .for_each(|n| n.add_offset(notif.height())); @@ -109,7 +141,7 @@ impl NotificationWidget { // Offset unfinished notifications by gap left from finished notifs for (offset, height) in finished.iter() { self.notifs.iter_mut().for_each(|n| { - if n.is_error() && n.offset() > *offset { + if n.get_type() == NotificationType::Error && n.offset() > *offset { n.add_offset(-(*height as i32)); } }) diff --git a/src/widget/notify_box.rs b/src/widget/notify_box.rs index 4906362..038a86d 100644 --- a/src/widget/notify_box.rs +++ b/src/widget/notify_box.rs @@ -7,7 +7,10 @@ use ratatui::{ use crate::{app::Context, style}; -use super::Corner; +use super::{ + notifications::{Notification, NotificationType}, + Corner, +}; impl Corner { fn is_top(&self) -> bool { @@ -120,7 +123,7 @@ impl AnimateState { } pub struct NotifyBox { - raw_content: String, + notif: Notification, pub time: f64, pub duration: f64, animation_speed: f64, @@ -133,26 +136,26 @@ pub struct NotifyBox { enter_state: AnimateState, leave_state: AnimateState, pub pos: Option<(i32, i32)>, - error: bool, + persist: bool, } impl NotifyBox { pub fn new( - content: String, + notif: Notification, duration: f64, position: Corner, animation_speed: f64, max_width: u16, - error: bool, + persist: bool, ) -> Self { - let raw_content = content.clone(); - let lines = textwrap::wrap(&content, max_width as usize); + //let raw_content = notif.content.clone(); + let lines = textwrap::wrap(¬if.content, max_width as usize); let actual_width = lines.iter().fold(0, |acc, x| acc.max(x.len())) as u16 + 2; let height = lines.len() as u16 + 2; NotifyBox { width: actual_width, height, - raw_content, + notif, position, animation_speed, max_width, @@ -163,7 +166,7 @@ impl NotifyBox { enter_state: AnimateState::new(), leave_state: AnimateState::new(), pos: None, - error, + persist, } } @@ -187,8 +190,8 @@ impl NotifyBox { self.time >= 1.0 } - pub fn is_error(&self) -> bool { - self.error + pub fn get_type(&self) -> NotificationType { + self.notif.notif_type } pub fn add_offset + Copy>(&mut self, offset: I) { @@ -199,11 +202,11 @@ impl NotifyBox { } pub fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let max_width = match self.error { - true => (area.width / 3).max(self.max_width), - false => area.width.min(self.max_width), + let max_width = match self.notif.notif_type { + NotificationType::Error => (area.width / 3).max(self.max_width), + _ => area.width.min(self.max_width), } as usize; - let lines = textwrap::wrap(&self.raw_content, max_width); + let lines = textwrap::wrap(&self.notif.content, max_width); self.width = lines.iter().fold(0, |acc, x| acc.max(x.len())) as u16 + 2; self.height = lines.len() as u16 + 2; let content = lines.join("\n"); @@ -240,29 +243,53 @@ impl NotifyBox { } let scroll_x = (pos.0 + 1).min(0).unsigned_abs() as u16; let scroll_y = (pos.1 + 1).min(0).unsigned_abs() as u16; - let block = match self.error { - false => Block::new() - .border_style(style!(fg:ctx.theme.border_focused_color)) + let (title, border_color, fg_color) = match self.notif.notif_type { + NotificationType::Info => (None, ctx.theme.info, ctx.theme.fg), + NotificationType::Warning => (Some("Warning"), ctx.theme.warning, ctx.theme.warning), + NotificationType::Error => ( + Some("Error: Press ESC to dismiss..."), + ctx.theme.error, + ctx.theme.error, + ), + NotificationType::Success => (Some("Success"), ctx.theme.success, ctx.theme.fg), + }; + let block = { + let mut block = Block::new() + .border_style(style!(fg:border_color)) .bg(ctx.theme.bg) - .fg(ctx.theme.fg) + .fg(fg_color) .borders(border) - .border_type(ctx.theme.border), - true => { - let mut block = Block::new() - .border_style(style!(fg:ctx.theme.error)) - .bg(ctx.theme.bg) - .fg(ctx.theme.error) - .borders(border) - .border_type(ctx.theme.border); - if border.contains(Borders::TOP) { - let title = "Error: Press ESC to dismiss..."; - if let Some(sub) = title.get((scroll_x as usize)..) { - block = block.title(sub); - } + .border_type(ctx.theme.border); + if border.contains(Borders::TOP) { + if let Some(sub) = title.and_then(|t| t.get((scroll_x as usize)..)) { + block = block.title(sub) } - block } + block }; + //let block = match self.notif_type { + // Notification::Error => { + // let mut block = Block::new() + // .border_style(style!(fg:ctx.theme.error)) + // .bg(ctx.theme.bg) + // .fg(ctx.theme.error) + // .borders(border) + // .border_type(ctx.theme.border); + // if border.contains(Borders::TOP) { + // let title = "Error: Press ESC to dismiss..."; + // if let Some(sub) = title.get((scroll_x as usize)..) { + // block = block.title(sub); + // } + // } + // block + // } + // _ => Block::new() + // .border_style(style!(fg:ctx.theme.border_focused_color)) + // .bg(ctx.theme.bg) + // .fg(ctx.theme.fg) + // .borders(border) + // .border_type(ctx.theme.border), + //}; super::clear(rect, f.buffer_mut(), ctx.theme.bg); Paragraph::new(content) @@ -293,7 +320,7 @@ impl NotifyBox { self.pos = Some(self.next_pos(deltatime, area)); // Dont automatically dismiss errors - if self.enter_state.is_done() && !self.error { + if self.enter_state.is_done() && !self.persist { self.time = 1.0_f64.min(self.time + deltatime / self.duration); } last_pos != self.pos diff --git a/src/widget/results.rs b/src/widget/results.rs index 4955fd6..da6da3b 100644 --- a/src/widget/results.rs +++ b/src/widget/results.rs @@ -16,7 +16,7 @@ use crate::{ widget::sort::SortDir, }; -use super::{border_block, centered_rect, Corner, VirtualStatefulTable}; +use super::{border_block, centered_rect, VirtualStatefulTable}; #[derive(Clone, Copy, PartialEq, Eq)] enum VisualMode { @@ -38,53 +38,64 @@ impl ResultsWidget { *self.table.state.offset_mut() = 0; } - fn try_select_add(&self, ctx: &mut Context, sel: usize) { - if let Some(item) = ctx.results.response.items.get(sel) { - if ctx.batch.iter().any(|s| s.id == item.id) { - ctx.batch.push(item.to_owned()); - } - } - } - - fn try_select_remove(&self, ctx: &mut Context, sel: usize) { - if let Some(item) = ctx.results.response.items.get(sel) { - if let Some(p) = ctx.batch.iter().position(|s| s.id == item.id) { - ctx.batch.remove(p); - } + fn try_select_add(&self, ctx: &mut Context, start: usize, stop: usize) { + if let Some(item) = ctx.results.response.items.get(start..stop) { + item.iter().for_each(|i| { + if !ctx.batch.iter().any(|s| s.id == i.id) { + ctx.batch.push(i.to_owned()); + } + }); } } - fn try_select_toggle(&self, ctx: &mut Context, sel: usize) { - if let Some(item) = ctx.results.response.items.get(sel) { - if let Some(p) = ctx.batch.iter().position(|s| s.id == item.id) { - ctx.batch.remove(p); - } else { - ctx.batch.push(item.to_owned()); - } + fn try_select_remove(&self, ctx: &mut Context, start: usize, stop: usize) { + if let Some(item) = ctx.results.response.items.get(start..stop) { + item.iter().for_each(|i| { + if let Some(p) = ctx.batch.iter().position(|s| s.id == i.id) { + ctx.batch.remove(p); + } + }) } } - fn try_select_toggle_range(&self, ctx: &mut Context, start: usize, stop: usize) { - for i in start..=stop { - self.try_select_toggle(ctx, i); + fn try_select_toggle(&self, ctx: &mut Context, start: usize, stop: usize) { + if let Some(item) = ctx.results.response.items.get(start..stop) { + item.iter().for_each(|i| { + if let Some(p) = ctx.batch.iter().position(|s| s.id == i.id) { + ctx.batch.remove(p); + } else { + ctx.batch.push(i.to_owned()); + } + }) } } - fn select_on_move(&self, ctx: &mut Context, start: usize, stop: usize) { - if start == stop { + fn select_on_move( + &self, + ctx: &mut Context, + prev: usize, + range_start: usize, + range_stop: usize, + ) { + if prev == range_stop { return; } match self.visual_mode { VisualMode::None => {} VisualMode::Toggle => { - if stop.abs_diff(self.visual_anchor) < start.abs_diff(self.visual_anchor) { - self.try_select_toggle(ctx, start) + if range_stop.abs_diff(self.visual_anchor) < prev.abs_diff(self.visual_anchor) { + let dir = (prev as isize - range_stop as isize).signum(); + self.try_select_toggle( + ctx, + range_start.saturating_add_signed(dir), + range_stop.saturating_add_signed(dir) + 1, + ) } else { - self.try_select_toggle(ctx, stop) + self.try_select_toggle(ctx, range_start, range_stop + 1) } } - VisualMode::Add => self.try_select_add(ctx, stop), - VisualMode::Remove => self.try_select_remove(ctx, stop), + VisualMode::Add => self.try_select_add(ctx, range_start, range_stop + 1), + VisualMode::Remove => self.try_select_remove(ctx, range_start, range_stop + 1), } } } @@ -136,16 +147,32 @@ impl super::Widget for ResultsWidget { let num_items = items.len(); let first_item = (ctx.page - 1) * 75; let focused = matches!(ctx.mode, Mode::Normal | Mode::KeyCombo(_)); + + let dl_src = title!( + "dl: {}, src: {}", + ctx.client.to_string(), + ctx.src.to_string() + ); + + let title = title!( + "Results {}-{} ({} total): Page {}/{}", + first_item + 1, + num_items + first_item, + ctx.results.response.total_results, + ctx.page, + ctx.results.response.last_page, + ); + let mut block = border_block(&ctx.theme, focused) + .title(title) + .title_top(Line::from(dl_src).right_aligned()); + if !ctx.last_key.is_empty() { + let key_str = title!(ctx.last_key); + block = block.title_bottom(Line::from(key_str).right_aligned()); + } + let table = Table::new(items, ctx.results.table.binding.to_owned()) .header(header) - .block(border_block(&ctx.theme, focused).title(title!( - "Results {}-{} ({} total): Page {}/{}", - first_item + 1, - num_items + first_item, - ctx.results.response.total_results, - ctx.page, - ctx.results.response.last_page, - ))) + .block(block) .highlight_style(Style::default().bg(ctx.theme.hl_bg)); let visible_height = area.height.saturating_sub(3) as usize; @@ -196,22 +223,6 @@ impl super::Widget for ResultsWidget { para.render(para_area, buf); } } - - let dl_src = title!( - "dl: {}, src: {}", - ctx.client.to_string(), - ctx.src.to_string() - ); - if let Some((tr, area)) = Corner::TopRight.try_title(dl_src, area, true) { - f.render_widget(tr, area); - } - - if !ctx.last_key.is_empty() { - let key_str = title!(ctx.last_key); - if let Some((br, area)) = Corner::BottomRight.try_title(key_str, area, true) { - f.render_widget(br, area); - } - } } fn handle_event(&mut self, ctx: &mut Context, e: &Event) { @@ -266,12 +277,12 @@ impl super::Widget for ResultsWidget { (Char('j') | KeyCode::Down, &KeyModifiers::NONE) => { let prev = self.table.selected().unwrap_or(0); let selected = self.table.next(ctx.results.response.items.len(), 1); - self.select_on_move(ctx, prev, selected); + self.select_on_move(ctx, prev, selected, selected); } (Char('k') | KeyCode::Up, &KeyModifiers::NONE) => { let prev = self.table.selected().unwrap_or(0); let selected = self.table.next(ctx.results.response.items.len(), -1); - self.select_on_move(ctx, prev, selected); + self.select_on_move(ctx, prev, selected, selected); //if self.control_space_toggle.is_some() && prev != selected { // self.try_select_toggle( // ctx, @@ -283,10 +294,14 @@ impl super::Widget for ResultsWidget { //} } (Char('J'), &KeyModifiers::SHIFT) => { - self.table.next(ctx.results.response.items.len(), 4); + let prev = self.table.selected().unwrap_or(0); + let selected = self.table.next(ctx.results.response.items.len(), 4); + self.select_on_move(ctx, prev, prev + 1, selected); } (Char('K'), &KeyModifiers::SHIFT) => { - self.table.next(ctx.results.response.items.len(), -4); + let prev = self.table.selected().unwrap_or(0); + let selected = self.table.next(ctx.results.response.items.len(), -4); + self.select_on_move(ctx, prev, selected, prev.saturating_sub(1)); } (Char('G'), &KeyModifiers::SHIFT) => { let prev = self.table.selected().unwrap_or(0); @@ -294,18 +309,13 @@ impl super::Widget for ResultsWidget { self.table.select(selected); if self.visual_mode != VisualMode::None && prev != selected { - self.try_select_toggle_range(ctx, prev + 1, selected); - //self.try_select_toggle( - // ctx, - // match selected <= self.visual_anchor { - // true => prev, - // false => selected, - // }, - //); + self.select_on_move(ctx, prev, prev + 1, selected); } } (Char('g'), &KeyModifiers::NONE) => { + let prev = self.table.selected().unwrap_or(0); self.table.select(0); + self.select_on_move(ctx, prev, 0, prev.saturating_sub(1)); } (Char('H') | Char('P'), &KeyModifiers::SHIFT) => { if ctx.page != 1 { @@ -343,44 +353,44 @@ impl super::Widget for ResultsWidget { .unwrap_or("https://nyaa.si".to_owned()); let res = open::that_detached(link.clone()); if let Err(e) = res { - ctx.show_error(format!("Failed to open {}:\n{}", link, e)); + ctx.notify_error(format!("Failed to open {}:\n{}", link, e)); } else { - ctx.notify(format!("Opened {}", link)); + ctx.notify_info(format!("Opened {}", link)); } } (Char('y'), &KeyModifiers::NONE) => ctx.mode = Mode::KeyCombo("y".to_string()), (Char(' '), &KeyModifiers::CONTROL) => { if self.visual_mode != VisualMode::Toggle { - ctx.notify("Entered VISUAL TOGGLE mode"); + ctx.notify_info("Entered VISUAL TOGGLE mode"); self.visual_anchor = self.table.selected().unwrap_or(0); - self.try_select_toggle(ctx, self.visual_anchor); + self.try_select_toggle(ctx, self.visual_anchor, self.visual_anchor + 1); self.visual_mode = VisualMode::Toggle; } else { - ctx.notify("Exited VISUAL TOGGLE mode"); + ctx.notify_info("Exited VISUAL TOGGLE mode"); self.visual_anchor = 0; self.visual_mode = VisualMode::None; } } (Char('v'), &KeyModifiers::NONE) => { if self.visual_mode != VisualMode::Add { - ctx.notify("Entered VISUAL ADD mode"); + ctx.notify_info("Entered VISUAL ADD mode"); self.visual_anchor = self.table.selected().unwrap_or(0); - self.try_select_add(ctx, self.visual_anchor); + self.try_select_add(ctx, self.visual_anchor, self.visual_anchor + 1); self.visual_mode = VisualMode::Add; } else { - ctx.notify("Exited VISUAL ADD mode"); + ctx.notify_info("Exited VISUAL ADD mode"); self.visual_anchor = 0; self.visual_mode = VisualMode::None; } } (Char('V'), &KeyModifiers::SHIFT) => { if self.visual_mode != VisualMode::Remove { - ctx.notify("Entered VISUAL REMOVE mode"); + ctx.notify_info("Entered VISUAL REMOVE mode"); self.visual_anchor = self.table.selected().unwrap_or(0); - self.try_select_remove(ctx, self.visual_anchor); + self.try_select_remove(ctx, self.visual_anchor, self.visual_anchor + 1); self.visual_mode = VisualMode::Remove; } else { - ctx.notify("Exited VISUAL REMOVE mode"); + ctx.notify_info("Exited VISUAL REMOVE mode"); self.visual_anchor = 0; self.visual_mode = VisualMode::None; } @@ -401,9 +411,9 @@ impl super::Widget for ResultsWidget { } (Esc, &KeyModifiers::NONE) => { match self.visual_mode { - VisualMode::Add => ctx.notify("Exited VISUAL ADD mode"), - VisualMode::Remove => ctx.notify("Exited VISUAL REMOVE mode"), - VisualMode::Toggle => ctx.notify("Exited VISUAL TOGGLE mode"), + VisualMode::Add => ctx.notify_info("Exited VISUAL ADD mode"), + VisualMode::Remove => ctx.notify_info("Exited VISUAL REMOVE mode"), + VisualMode::Toggle => ctx.notify_info("Exited VISUAL TOGGLE mode"), VisualMode::None => ctx.dismiss_notifications(), } self.visual_anchor = 0; diff --git a/src/widget/search.rs b/src/widget/search.rs index 61ce7b4..144c5a2 100644 --- a/src/widget/search.rs +++ b/src/widget/search.rs @@ -2,6 +2,7 @@ use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ layout::{Margin, Rect}, style::Stylize, + text::Line, widgets::{Clear, Widget}, Frame, }; @@ -14,7 +15,6 @@ use crate::{ use super::{ border_block, input::{self, InputWidget}, - Corner, }; pub struct SearchWidget { @@ -32,14 +32,6 @@ impl Default for SearchWidget { impl super::Widget for SearchWidget { fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { let buf = f.buffer_mut(); - let block = border_block(&ctx.theme, ctx.mode == Mode::Search).title(title!("Search")); - Clear.render(area, buf); - block.render(area, buf); - let input_area = area.inner(Margin { - vertical: 1, - horizontal: 1, - }); - let help_title = title!( "Press ".into(); "F1".bold(); @@ -47,9 +39,15 @@ impl super::Widget for SearchWidget { "?".bold(); " for help".into(); ); - if let Some((tr, area)) = Corner::TopRight.try_title(help_title, area, true) { - tr.render(area, buf); - } + let block = border_block(&ctx.theme, ctx.mode == Mode::Search) + .title(title!("Search")) + .title_top(Line::from(help_title).right_aligned()); + Clear.render(area, buf); + block.render(area, buf); + let input_area = area.inner(Margin { + vertical: 1, + horizontal: 1, + }); self.input.draw(f, ctx, input_area); if ctx.mode == Mode::Search { diff --git a/src/widget/sort.rs b/src/widget/sort.rs index 713b9dd..d11e91a 100644 --- a/src/widget/sort.rs +++ b/src/widget/sort.rs @@ -129,7 +129,7 @@ impl Widget for SortPopup { }; ctx.mode = Mode::Loading(LoadType::Sorting); if let Some(s) = ctx.src_info.sorts.get(i) { - ctx.notify(format!("Sort by \"{}\" {}", s, self.selected.dir)); + ctx.notify_info(format!("Sort by \"{}\" {}", s, self.selected.dir)); } } } diff --git a/src/widget/sources.rs b/src/widget/sources.rs index edd5e7f..17ffb79 100644 --- a/src/widget/sources.rs +++ b/src/widget/sources.rs @@ -74,8 +74,8 @@ impl Widget for SourcesPopup { ctx.mode = Mode::Loading(LoadType::Sourcing); src.load_config(&mut ctx.config.sources); match ctx.save_config() { - Ok(_) => ctx.notify(format!("Updated source to \"{}\"", src)), - Err(e) => ctx.show_error(format!( + Ok(_) => ctx.notify_info(format!("Updated source to \"{}\"", src)), + Err(e) => ctx.notify_error(format!( "Failed to update default source in config file:\n{}", e )), diff --git a/src/widget/themes.rs b/src/widget/themes.rs index 5f0263d..65eef67 100644 --- a/src/widget/themes.rs +++ b/src/widget/themes.rs @@ -131,13 +131,16 @@ impl Widget for ThemePopup { &ctx.theme, ); match ctx.save_config() { - Ok(_) => ctx.notify(format!("Updated theme to \"{}\"", theme_name)), - Err(e) => ctx.show_error(format!( + Ok(_) => { + ctx.notify_info(format!("Updated theme to \"{}\"", theme_name)) + } + Err(e) => ctx.notify_error(format!( "Failed to update default theme in config file:\n{}", e )), } } + ctx.mode = Mode::Normal; } _ => {} } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 71383d5..ae576af 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -3,7 +3,7 @@ use std::{error::Error, path::PathBuf}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use nyaa::{ app::App, - client::{Client, ClientConfig, DownloadResult}, + client::{Client, ClientConfig, DownloadClientResult}, config::{Config, ConfigManager}, results::Results, source::{Item, SourceResults}, @@ -162,7 +162,7 @@ pub fn print_buffer(buf: &Buffer) { impl EventSync for TestSync { async fn load_results( self, - tx_res: Sender>>, + _tx_res: Sender>>, _loadtype: nyaa::app::LoadType, _src: nyaa::source::Sources, _client: reqwest::Client, @@ -171,9 +171,9 @@ impl EventSync for TestSync { _theme: nyaa::theme::Theme, _date_format: Option, ) { - let _ = tx_res - .send(Ok(SourceResults::Results(Results::default()))) - .await; + //let _ = tx_res + // .send(Ok(SourceResults::Results(Results::default()))) + // .await; } async fn read_event_loop(self, tx_evt: Sender) { @@ -185,7 +185,7 @@ impl EventSync for TestSync { async fn download( self, - _tx_dl: Sender, + _tx_dl: Sender, _batch: bool, _items: Vec, _config: ClientConfig, diff --git a/tests/config/themes/dracula2.toml b/tests/config/themes/dracula2.toml index 9236972..40f83fb 100644 --- a/tests/config/themes/dracula2.toml +++ b/tests/config/themes/dracula2.toml @@ -7,5 +7,7 @@ border_focused_color = "LightCyan" hl_bg = "DarkGray" solid_bg = "White" solid_fg = "Black" -trusted = "Green" -remake = "Red" +info = "LightCyan" +success = "Green" +error = "Red" +warning = "Yellow" diff --git a/tests/popups.rs b/tests/popups.rs index ad0abfe..bd84eec 100644 --- a/tests/popups.rs +++ b/tests/popups.rs @@ -172,6 +172,7 @@ async fn test_themes() { .string('t') .string("jjj") .enter() + .string('t') .quit() .build(); @@ -199,7 +200,7 @@ async fn test_themes() { r#"│ │"#, r#"│ │"#, r#"│ │"#, - r#"╰──────────────────────────────────────────────────────╯"#, + r#"╰─────────────────────────────────────────────────────────t╯"#, ]) ); } @@ -259,7 +260,7 @@ async fn test_source() { r#"┌Search───────────────────────│Updated source to "Sukebei"│┐"#, r#"│ └───────────────────────────┘│"#, r#"└──────────────────────────────────────────────────────────┘"#, - r#"┌Results 1-0 (0 total): Page 1dl: Run Command, src: Sukebei┐"#, + r#"┌Results 1-0 (0 total): Page 1/0: Run Command, src: Sukebei┐"#, r#"│ │"#, r#"│ │"#, r#"│ │"#,