From ac0065043a36be885bc0dcd5902c3875abfd4ffe Mon Sep 17 00:00:00 2001 From: micielski <73398428+micielski@users.noreply.github.com> Date: Sat, 24 Aug 2024 17:13:18 +0200 Subject: [PATCH] feat: categories/labels (#98) --- rm-config/defaults/categories.toml | 6 ++ rm-config/defaults/config.toml | 7 +- rm-config/defaults/keymap.toml | 1 + rm-config/src/categories.rs | 99 +++++++++++++++++++ rm-config/src/keymap/actions/torrents_tab.rs | 3 + rm-config/src/lib.rs | 9 +- rm-config/src/main_config/torrents_tab.rs | 7 ++ rm-main/src/transmission/action.rs | 46 +++++++-- rm-main/src/transmission/fetchers.rs | 1 + rm-main/src/tui/components/input_manager.rs | 82 +++++++++++++-- rm-main/src/tui/components/table.rs | 7 +- rm-main/src/tui/components/tabs.rs | 2 +- rm-main/src/tui/tabs/search/bottom_bar.rs | 38 ++++++- rm-main/src/tui/tabs/search/mod.rs | 29 +++--- rm-main/src/tui/tabs/torrents/mod.rs | 30 +++--- rm-main/src/tui/tabs/torrents/popups/files.rs | 8 +- .../tui/tabs/torrents/rustmission_torrent.rs | 59 +++++++++++ .../src/tui/tabs/torrents/table_manager.rs | 12 ++- rm-main/src/tui/tabs/torrents/task_manager.rs | 94 +++++++++--------- .../src/tui/tabs/torrents/tasks/add_magnet.rs | 99 ++++++++++++++++--- .../tabs/torrents/tasks/change_category.rs | 72 ++++++++++++++ .../src/tui/tabs/torrents/tasks/default.rs | 6 +- .../tui/tabs/torrents/tasks/delete_torrent.rs | 53 ++++------ rm-main/src/tui/tabs/torrents/tasks/filter.rs | 20 ++-- rm-main/src/tui/tabs/torrents/tasks/mod.rs | 21 ++-- .../tui/tabs/torrents/tasks/move_torrent.rs | 9 +- rm-main/src/tui/tabs/torrents/tasks/status.rs | 12 +-- rm-shared/src/action.rs | 12 ++- rm-shared/src/header.rs | 8 +- rm-shared/src/status_task.rs | 17 ++++ 30 files changed, 686 insertions(+), 183 deletions(-) create mode 100644 rm-config/defaults/categories.toml create mode 100644 rm-config/src/categories.rs create mode 100644 rm-main/src/tui/tabs/torrents/tasks/change_category.rs diff --git a/rm-config/defaults/categories.toml b/rm-config/defaults/categories.toml new file mode 100644 index 0000000..1408fe7 --- /dev/null +++ b/rm-config/defaults/categories.toml @@ -0,0 +1,6 @@ +# Example category: +# [[category]] +# name = "Classical Music" # required +# icon = "[M]" # optional, default: "" +# default_dir = "/mnt/Music/Classical" # optional, default: transmission's default +# color = "Green" # optional, default: "White" diff --git a/rm-config/defaults/config.toml b/rm-config/defaults/config.toml index 0d8cca8..994849e 100644 --- a/rm-config/defaults/config.toml +++ b/rm-config/defaults/config.toml @@ -31,9 +31,14 @@ free_space_refresh = 10 # Available fields: # Id, Name, SizeWhenDone, Progress, Eta, DownloadRate, UploadRate, DownloadDir, # Padding, UploadRatio, UploadedEver, AddedDate, ActivityDate, PeersConnected -# SmallStatus +# SmallStatus, Category, CategoryIcon headers = ["Name", "SizeWhenDone", "Progress", "Eta", "DownloadRate", "UploadRate"] +# Whether to insert category icon into name as declared in categories.toml. +# An alternative to inserting category's icon into torrent's name is adding a +# CategoryIcon header into your headers. +category_icon_insert_into_name = true + [search_tab] # If you uncomment this, providers won't be automatically added in future # versions of Rustmission. diff --git a/rm-config/defaults/keymap.toml b/rm-config/defaults/keymap.toml index c0b2d50..73f7b95 100644 --- a/rm-config/defaults/keymap.toml +++ b/rm-config/defaults/keymap.toml @@ -39,6 +39,7 @@ keybindings = [ keybindings = [ { on = "a", action = "AddMagnet" }, { on = "m", action = "MoveTorrent" }, + { on = "c", action = "ChangeCategory" }, { on = "p", action = "Pause" }, { on = "f", action = "ShowFiles" }, { on = "s", action = "ShowStats" }, diff --git a/rm-config/src/categories.rs b/rm-config/src/categories.rs new file mode 100644 index 0000000..a3538fa --- /dev/null +++ b/rm-config/src/categories.rs @@ -0,0 +1,99 @@ +use std::{collections::HashMap, io::ErrorKind, path::PathBuf, sync::OnceLock}; + +use anyhow::{Context, Result}; +use ratatui::style::Color; +use serde::Deserialize; + +use crate::utils::{self, ConfigFetchingError}; + +#[derive(Deserialize)] +pub struct CategoriesConfig { + #[serde(default)] + pub categories: Vec, + #[serde(skip)] + pub map: HashMap, + #[serde(skip)] + pub max_name_len: u8, + #[serde(skip)] + pub max_icon_len: u8, +} + +#[derive(Deserialize, Clone)] +pub struct Category { + pub name: String, + pub icon: String, + pub color: Color, + pub default_dir: String, +} + +impl CategoriesConfig { + pub(crate) const FILENAME: &'static str = "categories.toml"; + pub const DEFAULT_CONFIG: &'static str = include_str!("../defaults/categories.toml"); + + pub(crate) fn init() -> Result { + match utils::fetch_config::(Self::FILENAME) { + Ok(mut config) => { + config.after_init(); + Ok(config) + } + Err(e) => match e { + ConfigFetchingError::Io(e) if e.kind() == ErrorKind::NotFound => { + let mut config = + utils::put_config::(Self::DEFAULT_CONFIG, Self::FILENAME)?; + config.after_init(); + Ok(config) + } + ConfigFetchingError::Toml(e) => Err(e).with_context(|| { + format!( + "Failed to parse config located at {:?}", + utils::get_config_path(Self::FILENAME) + ) + }), + _ => anyhow::bail!(e), + }, + } + } + + pub(crate) fn path() -> &'static PathBuf { + static PATH: OnceLock = OnceLock::new(); + PATH.get_or_init(|| utils::get_config_path(Self::FILENAME)) + } +} + +impl CategoriesConfig { + pub fn is_empty(&self) -> bool { + self.categories.is_empty() + } + + fn after_init(&mut self) { + self.populate_hashmap(); + self.set_lengths(); + } + + fn populate_hashmap(&mut self) { + for category in &self.categories { + self.map.insert(category.name.clone(), category.clone()); + } + } + + fn set_lengths(&mut self) { + let mut max_icon_len = 0u8; + let mut max_name_len = 0u8; + + for category in &self.categories { + let name_len = u8::try_from(category.name.chars().count()).unwrap_or(u8::MAX); + let icon_len = u8::try_from(category.icon.chars().count()).unwrap_or(u8::MAX); + + if name_len > max_name_len { + max_name_len = name_len; + } + + if icon_len > max_icon_len { + max_icon_len = icon_len + } + } + + self.max_name_len = max_name_len; + self.max_icon_len = max_icon_len; + } +} diff --git a/rm-config/src/keymap/actions/torrents_tab.rs b/rm-config/src/keymap/actions/torrents_tab.rs index ecef13b..e6c2712 100644 --- a/rm-config/src/keymap/actions/torrents_tab.rs +++ b/rm-config/src/keymap/actions/torrents_tab.rs @@ -12,6 +12,7 @@ pub enum TorrentsAction { DeleteWithoutFiles, ShowFiles, ShowStats, + ChangeCategory, } impl UserAction for TorrentsAction { @@ -24,6 +25,7 @@ impl UserAction for TorrentsAction { TorrentsAction::DeleteWithoutFiles => "delete without files", TorrentsAction::ShowFiles => "show files", TorrentsAction::ShowStats => "show statistics", + TorrentsAction::ChangeCategory => "change category", } } } @@ -38,6 +40,7 @@ impl From for Action { TorrentsAction::DeleteWithoutFiles => Action::DeleteWithoutFiles, TorrentsAction::ShowFiles => Action::ShowFiles, TorrentsAction::ShowStats => Action::ShowStats, + TorrentsAction::ChangeCategory => Action::ChangeCategory, } } } diff --git a/rm-config/src/lib.rs b/rm-config/src/lib.rs index 0506ac6..d1c7383 100644 --- a/rm-config/src/lib.rs +++ b/rm-config/src/lib.rs @@ -1,3 +1,4 @@ +pub mod categories; pub mod keymap; pub mod main_config; mod utils; @@ -5,6 +6,7 @@ mod utils; use std::{path::PathBuf, sync::LazyLock}; use anyhow::Result; +use categories::CategoriesConfig; use keymap::KeymapConfig; use main_config::MainConfig; @@ -22,22 +24,26 @@ pub struct Config { pub search_tab: main_config::SearchTab, pub icons: main_config::Icons, pub keybindings: KeymapConfig, + pub categories: CategoriesConfig, pub directories: Directories, } pub struct Directories { pub main_path: &'static PathBuf, pub keymap_path: &'static PathBuf, + pub categories_path: &'static PathBuf, } impl Config { fn init() -> Result { let main_config = MainConfig::init()?; let keybindings = KeymapConfig::init()?; + let categories = CategoriesConfig::init()?; let directories = Directories { main_path: MainConfig::path(), keymap_path: KeymapConfig::path(), + categories_path: CategoriesConfig::path(), }; Ok(Self { @@ -46,7 +52,8 @@ impl Config { torrents_tab: main_config.torrents_tab, search_tab: main_config.search_tab, icons: main_config.icons, - keybindings: keybindings.clone(), + keybindings, + categories, directories, }) } diff --git a/rm-config/src/main_config/torrents_tab.rs b/rm-config/src/main_config/torrents_tab.rs index 92c3f6d..d82a9fe 100644 --- a/rm-config/src/main_config/torrents_tab.rs +++ b/rm-config/src/main_config/torrents_tab.rs @@ -5,6 +5,12 @@ use serde::Deserialize; pub struct TorrentsTab { #[serde(default = "default_headers")] pub headers: Vec
, + #[serde(default = "default_true")] + pub category_icon_insert_into_name: bool, +} + +fn default_true() -> bool { + true } fn default_headers() -> Vec
{ @@ -22,6 +28,7 @@ impl Default for TorrentsTab { fn default() -> Self { Self { headers: default_headers(), + category_icon_insert_into_name: default_true(), } } } diff --git a/rm-main/src/transmission/action.rs b/rm-main/src/transmission/action.rs index b314b27..0bf2c99 100644 --- a/rm-main/src/transmission/action.rs +++ b/rm-main/src/transmission/action.rs @@ -15,14 +15,16 @@ use rm_shared::action::UpdateAction; const FAILED_TO_COMMUNICATE: &str = "Failed to communicate with Transmission"; pub enum TorrentAction { - // Add a torrent with this Magnet/URL, Directory - Add(String, Option), + // Add a torrent with this Magnet/URL, Directory, Label (Category) + Add(String, Option, Option), // Stop Torrents with these given IDs Stop(Vec), // Start Torrents with these given IDs Start(Vec), // Torrent ID, Directory to move to Move(Vec, String), + // Torrent ID, Category to set + ChangeCategory(Vec, String), // Delete Torrents with these given IDs (without files) DelWithoutFiles(Vec), // Delete Torrents with these given IDs (with files) @@ -51,7 +53,7 @@ pub async fn action_handler( ) { while let Some(action) = trans_rx.recv().await { match action { - TorrentAction::Add(ref url, directory) => { + TorrentAction::Add(ref url, directory, label) => { let formatted = { if url.starts_with("www") { format!("https://{url}") @@ -59,14 +61,18 @@ pub async fn action_handler( url.to_string() } }; + + let label = label.map(|label| vec![label]); + let args = TorrentAddArgs { filename: Some(formatted), download_dir: directory, + labels: label, ..Default::default() }; match client.torrent_add(args).await { Ok(_) => { - action_tx.send(UpdateAction::TaskSuccess).unwrap(); + action_tx.send(UpdateAction::StatusTaskSuccess).unwrap(); } Err(err) => { let msg = format!("Failed to add torrent with URL/Path: \"{url}\""); @@ -74,7 +80,7 @@ pub async fn action_handler( action_tx .send(UpdateAction::Error(Box::new(err_message))) .unwrap(); - action_tx.send(UpdateAction::TaskFailure).unwrap(); + action_tx.send(UpdateAction::StatusTaskFailure).unwrap(); } } } @@ -104,27 +110,27 @@ pub async fn action_handler( } TorrentAction::DelWithFiles(ids) => { match client.torrent_remove(ids.clone(), true).await { - Ok(_) => action_tx.send(UpdateAction::TaskSuccess).unwrap(), + Ok(_) => action_tx.send(UpdateAction::StatusTaskSuccess).unwrap(), Err(err) => { let msg = format!("Failed to remove torrents with these IDs: {:?}", ids); let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); action_tx .send(UpdateAction::Error(Box::new(err_message))) .unwrap(); - action_tx.send(UpdateAction::TaskFailure).unwrap(); + action_tx.send(UpdateAction::StatusTaskFailure).unwrap(); } } } TorrentAction::DelWithoutFiles(ids) => { match client.torrent_remove(ids.clone(), false).await { - Ok(_) => action_tx.send(UpdateAction::TaskSuccess).unwrap(), + Ok(_) => action_tx.send(UpdateAction::StatusTaskSuccess).unwrap(), Err(err) => { let msg = format!("Failed to remove torrents with these IDs: {:?}", ids); let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); action_tx .send(UpdateAction::Error(Box::new(err_message))) .unwrap(); - action_tx.send(UpdateAction::TaskFailure).unwrap(); + action_tx.send(UpdateAction::StatusTaskFailure).unwrap(); } } } @@ -203,6 +209,28 @@ pub async fn action_handler( } } } + TorrentAction::ChangeCategory(ids, category) => { + let labels = if category.is_empty() { + vec![] + } else { + vec![category] + }; + let args = TorrentSetArgs { + labels: Some(labels), + ..Default::default() + }; + match client.torrent_set(args, Some(ids)).await { + Ok(_) => action_tx.send(UpdateAction::StatusTaskSuccess).unwrap(), + Err(err) => { + let msg = "Failed to set category"; + let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); + action_tx + .send(UpdateAction::Error(Box::new(err_message))) + .unwrap(); + action_tx.send(UpdateAction::StatusTaskFailure).unwrap(); + } + } + } } } } diff --git a/rm-main/src/transmission/fetchers.rs b/rm-main/src/transmission/fetchers.rs index 1d1ef06..90d187e 100644 --- a/rm-main/src/transmission/fetchers.rs +++ b/rm-main/src/transmission/fetchers.rs @@ -84,6 +84,7 @@ pub async fn torrents(ctx: app::Ctx) { TorrentGetField::PeersConnected, TorrentGetField::Error, TorrentGetField::ErrorString, + TorrentGetField::Labels, ]; let (torrents_tx, torrents_rx) = oneshot::channel(); ctx.send_torrent_action(TorrentAction::GetTorrents(fields, torrents_tx)); diff --git a/rm-main/src/tui/components/input_manager.rs b/rm-main/src/tui/components/input_manager.rs index 9a5e293..28a2c9a 100644 --- a/rm-main/src/tui/components/input_manager.rs +++ b/rm-main/src/tui/components/input_manager.rs @@ -1,16 +1,17 @@ -use crossterm::event::{Event, KeyEvent}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ prelude::*, widgets::{Clear, Paragraph}, }; use rm_config::CONFIG; -use tui_input::{backend::crossterm::to_input_request, Input, InputResponse}; +use tui_input::{backend::crossterm::to_input_request, Input, InputResponse, StateChanged}; use crate::tui::components::Component; pub struct InputManager { input: Input, prompt: String, + autocompletions: Vec, } impl InputManager { @@ -18,6 +19,7 @@ impl InputManager { Self { prompt, input: Input::default(), + autocompletions: vec![], } } @@ -25,14 +27,60 @@ impl InputManager { Self { prompt, input: Input::default().with_value(value), + autocompletions: vec![], } } + pub fn autocompletions(mut self, autocompletions: Vec) -> Self { + self.autocompletions = autocompletions; + self + } + + pub fn get_autocompletion(&self) -> Option<&str> { + let mut autocompletion = None; + for possible_autocompletion in &self.autocompletions { + if possible_autocompletion.starts_with(&self.input.to_string()) { + autocompletion = Some(possible_autocompletion); + } + } + autocompletion.map(|x| x.as_str()) + } + + pub fn apply_autocompletion(&mut self) { + let completion = self.get_autocompletion().map(|str| str.to_string()); + if let Some(completion) = completion { + self.set_text(completion); + } + } + + pub fn visual_cursor(&self) -> usize { + self.input.visual_cursor() + } + pub fn text(&self) -> String { self.input.to_string() } pub fn handle_key(&mut self, key: KeyEvent) -> InputResponse { + if key.code == KeyCode::Tab { + self.apply_autocompletion(); + return Some(StateChanged { + value: true, + cursor: true, + }); + } + + if (self.visual_cursor() == self.text().len()) + && ((key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('f')) + || key.code == KeyCode::Right) + { + self.apply_autocompletion(); + return Some(StateChanged { + value: true, + cursor: true, + }); + } + let event = Event::Key(key); if let Some(req) = to_input_request(&event) { @@ -41,29 +89,47 @@ impl InputManager { None } } + + pub fn set_prompt(&mut self, new_prompt: impl Into) { + self.prompt = new_prompt.into(); + } + + pub fn set_text(&mut self, new_text: impl Into) { + self.input = self.input.clone().with_value(new_text.into()); + } } impl Component for InputManager { fn render(&mut self, f: &mut Frame, rect: Rect) { f.render_widget(Clear, rect); + let input = self.input.to_string(); let spans = vec![ Span::styled( self.prompt.as_str(), Style::default().fg(CONFIG.general.accent_color), ), - Span::raw(self.text()), + Span::styled(self.text(), Style::default().fg(Color::White)), ]; - let input = self.input.to_string(); - let prefix_len = self.prompt.len() + self.text().len() - input.len(); - let paragraph = Paragraph::new(Line::from(spans)); f.render_widget(paragraph, rect); - let cursor_offset = self.input.visual_cursor() + prefix_len; + let prefix_len = + u16::try_from(self.prompt.len() + self.text().len() - input.len()).unwrap(); + if let Some(completion) = self.get_autocompletion() { + let already_typed = u16::try_from(input.chars().count()).unwrap(); + let span = Span::from(&completion[already_typed as usize..]).dark_gray(); + let completion_rect = rect.inner(Margin { + horizontal: prefix_len + already_typed, + vertical: 0, + }); + f.render_widget(span, completion_rect); + } + + let cursor_offset = u16::try_from(self.input.visual_cursor()).unwrap() + prefix_len; let cursor_position = Position { - x: rect.x + u16::try_from(cursor_offset).unwrap(), + x: rect.x + cursor_offset, y: rect.y, }; f.set_cursor_position(cursor_position); diff --git a/rm-main/src/tui/components/table.rs b/rm-main/src/tui/components/table.rs index 0e126ad..7917205 100644 --- a/rm-main/src/tui/components/table.rs +++ b/rm-main/src/tui/components/table.rs @@ -105,6 +105,11 @@ impl GenericTable { } pub fn select_last(&mut self) { - self.state.borrow_mut().select_last(); + if self.items.is_empty() { + return; + } + + let mut state = self.state.borrow_mut(); + state.select(Some(self.items.len() - 1)); } } diff --git a/rm-main/src/tui/components/tabs.rs b/rm-main/src/tui/components/tabs.rs index 2b112e2..d110458 100644 --- a/rm-main/src/tui/components/tabs.rs +++ b/rm-main/src/tui/components/tabs.rs @@ -68,7 +68,7 @@ impl Component for TabComponent { let tabs = if CONFIG.general.beginner_mode { ["1. Torrents", "2. Search"] } else { - ["1. Torrents", "2. Search"] + ["Torrents", "Search"] }; tabs.concat().chars().count() + divider.len() + self.tabs_list.len() diff --git a/rm-main/src/tui/tabs/search/bottom_bar.rs b/rm-main/src/tui/tabs/search/bottom_bar.rs index f93ca82..bb46ce4 100644 --- a/rm-main/src/tui/tabs/search/bottom_bar.rs +++ b/rm-main/src/tui/tabs/search/bottom_bar.rs @@ -9,25 +9,57 @@ use rm_config::CONFIG; use rm_shared::action::{Action, UpdateAction}; use throbber_widgets_tui::ThrobberState; -use crate::tui::{app, components::Component}; +use crate::tui::{ + app, + components::{Component, ComponentAction}, + tabs::torrents::tasks, +}; use super::{ConfiguredProvider, ProviderState}; pub struct BottomBar { pub search_state: SearchState, + pub task: Option, + ctx: app::Ctx, } impl BottomBar { pub fn new(ctx: app::Ctx, providers: &Vec) -> Self { Self { - search_state: SearchState::new(ctx, providers), + search_state: SearchState::new(ctx.clone(), providers), + ctx, + task: None, } } + + pub fn add_magnet(&mut self, magnet: impl Into) { + self.task = Some(tasks::AddMagnet::new(self.ctx.clone()).magnet(magnet)); + self.ctx.send_update_action(UpdateAction::SwitchToInputMode); + } + + pub fn requires_input(&self) -> bool { + self.task.is_some() + } } impl Component for BottomBar { fn render(&mut self, f: &mut Frame, rect: Rect) { - self.search_state.render(f, rect); + if let Some(task) = &mut self.task { + task.render(f, rect); + } else { + self.search_state.render(f, rect); + } + } + + fn handle_actions(&mut self, action: Action) -> ComponentAction { + if let Some(task) = &mut self.task { + if task.handle_actions(action).is_quit() { + self.task = None; + self.ctx + .send_update_action(UpdateAction::SwitchToNormalMode); + }; + } + ComponentAction::Nothing } fn tick(&mut self) { diff --git a/rm-main/src/tui/tabs/search/mod.rs b/rm-main/src/tui/tabs/search/mod.rs index 019712b..45da61c 100644 --- a/rm-main/src/tui/tabs/search/mod.rs +++ b/rm-main/src/tui/tabs/search/mod.rs @@ -18,12 +18,9 @@ use rm_config::CONFIG; use tokio::sync::mpsc::{self, UnboundedSender}; use tui_input::{backend::crossterm::to_input_request, Input}; -use crate::{ - transmission::TorrentAction, - tui::{ - app, - components::{Component, ComponentAction, GenericTable}, - }, +use crate::tui::{ + app, + components::{Component, ComponentAction, GenericTable}, }; use rm_shared::{ action::{Action, UpdateAction}, @@ -129,11 +126,10 @@ impl SearchTab { self.ctx.send_action(Action::Render); } - fn add_torrent(&mut self) { + fn add_magnet(&mut self) { let magnet_url = self.table.current_item().map(|magnet| magnet.url); if let Some(magnet_url) = magnet_url { - self.ctx - .send_torrent_action(TorrentAction::Add(magnet_url, None)); + self.bottom_bar.add_magnet(magnet_url); } } @@ -142,10 +138,12 @@ impl SearchTab { match input.code { KeyCode::Enter => { - self.search_query_rx.send(self.input.to_string()).unwrap(); - self.focus = SearchTabFocus::List; - self.ctx - .send_update_action(UpdateAction::SwitchToNormalMode); + if !self.input.to_string().is_empty() { + self.search_query_rx.send(self.input.to_string()).unwrap(); + self.focus = SearchTabFocus::List; + self.ctx + .send_update_action(UpdateAction::SwitchToNormalMode); + } } KeyCode::Esc => { self.focus = SearchTabFocus::List; @@ -271,6 +269,9 @@ impl Component for SearchTab { A::Quit => self.ctx.send_action(Action::Quit), A::Search => self.start_search(), A::ChangeFocus => self.change_focus(), + A::Input(_) if self.bottom_bar.requires_input() => { + self.bottom_bar.handle_actions(action); + } A::Input(input) => self.handle_input(input), A::Down => self.next_torrent(), A::Up => self.previous_torrent(), @@ -280,7 +281,7 @@ impl Component for SearchTab { A::ScrollUpPage => self.scroll_up_page(), A::Home => self.scroll_to_home(), A::End => self.scroll_to_end(), - A::Confirm => self.add_torrent(), + A::Confirm => self.add_magnet(), A::XdgOpen => self.xdg_open(), A::ShowProvidersInfo => self.show_providers_info(), diff --git a/rm-main/src/tui/tabs/torrents/mod.rs b/rm-main/src/tui/tabs/torrents/mod.rs index 766cc29..73bcee9 100644 --- a/rm-main/src/tui/tabs/torrents/mod.rs +++ b/rm-main/src/tui/tabs/torrents/mod.rs @@ -93,23 +93,32 @@ impl Component for TorrentsTab { A::Pause => self.pause_current_torrent(), A::DeleteWithFiles => { if let Some(torrent) = self.table_manager.current_torrent() { - self.task_manager - .delete_torrent(torrent, tasks::delete_torrent::Mode::WithFiles); + self.task_manager.delete_torrent(torrent, true); } } A::DeleteWithoutFiles => { if let Some(torrent) = self.table_manager.current_torrent() { - self.task_manager - .delete_torrent(torrent, tasks::delete_torrent::Mode::WithoutFiles); + self.task_manager.delete_torrent(torrent, false); } } A::AddMagnet => self.task_manager.add_magnet(), - A::Search => self.task_manager.search(&self.table_manager.filter), + A::Search => self.task_manager.search( + &self + .table_manager + .filter + .as_ref() + .and_then(|f| Some(f.pattern.clone())), + ), A::MoveTorrent => { if let Some(torrent) = self.table_manager.current_torrent() { self.task_manager.move_torrent(torrent); } } + A::ChangeCategory => { + if let Some(torrent) = self.table_manager.current_torrent() { + self.task_manager.change_category(torrent); + } + } A::XdgOpen => self.xdg_open_current_torrent(), other => { self.task_manager.handle_actions(other); @@ -300,12 +309,11 @@ impl TorrentsTab { if let Some(torrent) = self.table_manager.current_torrent() { let torrent_location = torrent.torrent_location(); match open::that_detached(&torrent_location) { - Ok(()) => { - self.ctx - .send_update_action(UpdateAction::TaskSetSuccess(StatusTask::new_open( - torrent_location, - ))) - } + Ok(()) => self + .ctx + .send_update_action(UpdateAction::StatusTaskSetSuccess(StatusTask::new_open( + torrent_location, + ))), Err(err) => { let desc = format!( "Encountered an error while trying to open \"{}\"", diff --git a/rm-main/src/tui/tabs/torrents/popups/files.rs b/rm-main/src/tui/tabs/torrents/popups/files.rs index 5921e9f..53a7f52 100644 --- a/rm-main/src/tui/tabs/torrents/popups/files.rs +++ b/rm-main/src/tui/tabs/torrents/popups/files.rs @@ -216,9 +216,11 @@ impl Component for FilesPopup { let path = format!("{}/{}", torrent.download_dir.as_ref().unwrap(), sub_path,); match open::that_detached(&path) { - Ok(()) => self.ctx.send_update_action(UpdateAction::TaskSetSuccess( - StatusTask::new_open(&path), - )), + Ok(()) => self + .ctx + .send_update_action(UpdateAction::StatusTaskSetSuccess( + StatusTask::new_open(&path), + )), Err(err) => { let desc = format!("An error occured while trying to open \"{}\"", path); diff --git a/rm-main/src/tui/tabs/torrents/rustmission_torrent.rs b/rm-main/src/tui/tabs/torrents/rustmission_torrent.rs index 0cb9959..60b2ab4 100644 --- a/rm-main/src/tui/tabs/torrents/rustmission_torrent.rs +++ b/rm-main/src/tui/tabs/torrents/rustmission_torrent.rs @@ -28,6 +28,7 @@ pub struct RustmissionTorrent { pub activity_date: NaiveDateTime, pub added_date: NaiveDateTime, pub peers_connected: i64, + pub categories: Vec, pub error: Option, } @@ -48,6 +49,7 @@ impl RustmissionTorrent { headers: &Vec
, ) -> ratatui::widgets::Row { let mut torrent_name_line = Line::default(); + torrent_name_line.push_span(self.category_icon_span()); let char_indices: Vec = self.torrent_name.char_indices().map(|(i, _)| i).collect(); let mut last_end = 0; @@ -130,11 +132,45 @@ impl RustmissionTorrent { format!("{}/{}", self.download_dir, self.torrent_name) } + fn category_icon_span(&self) -> Span { + if let Some(category) = self + .categories + .first() + .and_then(|category| CONFIG.categories.map.get(category)) + { + Span::styled( + format!("{} ", category.icon), + Style::default().fg(category.color), + ) + } else { + Span::default() + } + } + + fn torrent_name_with_category_icon(&self) -> Line<'_> { + let mut line = Line::default(); + if let Some(category) = self + .categories + .first() + .and_then(|category| CONFIG.categories.map.get(category)) + { + line.push_span(Span::styled( + category.icon.as_str(), + Style::default().fg(category.color), + )); + line.push_span(Span::raw(" ")); + } + line.push_span(self.torrent_name.as_str()); + line + } + fn header_to_cell(&self, header: Header) -> Cell { match header { Header::Name => { if let Some(error) = &self.error { Cell::from(format!("{}\n{error}", self.torrent_name)) + } else if CONFIG.torrents_tab.category_icon_insert_into_name { + Cell::from(self.torrent_name_with_category_icon()) } else { Cell::from(self.torrent_name.as_str()) } @@ -176,6 +212,26 @@ impl RustmissionTorrent { } } } + Header::Category => match self.categories.first() { + Some(category) => { + if let Some(config_category) = CONFIG.categories.map.get(category) { + Cell::from(category.as_str()).fg(config_category.color) + } else { + Cell::from(category.as_str()) + } + } + None => Cell::default(), + }, + Header::CategoryIcon => match self.categories.first() { + Some(category) => { + if let Some(config_category) = CONFIG.categories.map.get(category) { + Cell::from(config_category.icon.as_str()).fg(config_category.color) + } else { + Cell::default() + } + } + None => Cell::default(), + }, } } @@ -271,6 +327,8 @@ impl From for RustmissionTorrent { } }; + let categories = t.labels.unwrap(); + Self { torrent_name, size_when_done, @@ -287,6 +345,7 @@ impl From for RustmissionTorrent { activity_date, added_date, peers_connected, + categories, error, } } diff --git a/rm-main/src/tui/tabs/torrents/table_manager.rs b/rm-main/src/tui/tabs/torrents/table_manager.rs index 74e5b13..49aef84 100644 --- a/rm-main/src/tui/tabs/torrents/table_manager.rs +++ b/rm-main/src/tui/tabs/torrents/table_manager.rs @@ -123,7 +123,17 @@ impl TableManager { let mut constraints = vec![]; for header in headers { - constraints.push(header.default_constraint()) + if *header == Header::Category { + constraints.push(Constraint::Length(u16::from( + CONFIG.categories.max_name_len, + ))) + } else if *header == Header::CategoryIcon { + constraints.push(Constraint::Length(u16::from( + CONFIG.categories.max_icon_len, + ))) + } else { + constraints.push(header.default_constraint()) + } } constraints } diff --git a/rm-main/src/tui/tabs/torrents/task_manager.rs b/rm-main/src/tui/tabs/torrents/task_manager.rs index bc6a7e4..dbeadb0 100644 --- a/rm-main/src/tui/tabs/torrents/task_manager.rs +++ b/rm-main/src/tui/tabs/torrents/task_manager.rs @@ -14,15 +14,7 @@ use crate::tui::{ use super::{ rustmission_torrent::RustmissionTorrent, - table_manager::Filter, - tasks::{ - add_magnet::AddMagnetBar, - default::DefaultBar, - delete_torrent::{self, DeleteBar, TorrentInfo}, - filter::FilterBar, - move_torrent::MoveBar, - status::{CurrentTaskState, StatusBar}, - }, + tasks::{self, CurrentTaskState}, }; pub struct TaskManager { @@ -33,19 +25,20 @@ pub struct TaskManager { impl TaskManager { pub fn new(ctx: app::Ctx) -> Self { Self { - current_task: CurrentTask::Default(DefaultBar::new()), + current_task: CurrentTask::Default(tasks::Default::new()), ctx, } } } pub enum CurrentTask { - AddMagnetBar(AddMagnetBar), - DeleteBar(DeleteBar), - FilterBar(FilterBar), - MoveBar(MoveBar), - Default(DefaultBar), - Status(StatusBar), + AddMagnet(tasks::AddMagnet), + Delete(tasks::Delete), + Filter(tasks::Filter), + Move(tasks::Move), + ChangeCategory(tasks::ChangeCategory), + Default(tasks::Default), + Status(tasks::Status), } impl CurrentTask { @@ -60,22 +53,22 @@ impl Component for TaskManager { #[must_use] fn handle_actions(&mut self, action: Action) -> ComponentAction { match &mut self.current_task { - CurrentTask::AddMagnetBar(magnet_bar) => { + CurrentTask::AddMagnet(magnet_bar) => { if magnet_bar.handle_actions(action).is_quit() { self.cancel_task() } } - CurrentTask::DeleteBar(delete_bar) => { + CurrentTask::Delete(delete_bar) => { if delete_bar.handle_actions(action).is_quit() { self.cancel_task() } } - CurrentTask::MoveBar(move_bar) => { + CurrentTask::Move(move_bar) => { if move_bar.handle_actions(action).is_quit() { self.cancel_task() } } - CurrentTask::FilterBar(filter_bar) => { + CurrentTask::Filter(filter_bar) => { if filter_bar.handle_actions(action).is_quit() { self.cancel_task() } @@ -85,23 +78,27 @@ impl Component for TaskManager { self.cancel_task() } } - - _ => (), + CurrentTask::ChangeCategory(category_bar) => { + if category_bar.handle_actions(action).is_quit() { + self.cancel_task() + } + } + CurrentTask::Default(_) => (), }; ComponentAction::Nothing } fn handle_update_action(&mut self, action: UpdateAction) { match action { - UpdateAction::TaskClear => self.cancel_task(), - UpdateAction::TaskSet(task) => self.pending_task(task), - UpdateAction::TaskSetSuccess(task) => self.success_task(task), - UpdateAction::TaskSuccess => { + UpdateAction::StatusTaskClear => self.cancel_task(), + UpdateAction::StatusTaskSet(task) => self.pending_task(task), + UpdateAction::StatusTaskSetSuccess(task) => self.success_task(task), + UpdateAction::StatusTaskSuccess => { if let CurrentTask::Status(status_bar) = &mut self.current_task { status_bar.set_success(); } } - UpdateAction::TaskFailure => { + UpdateAction::StatusTaskFailure => { if let CurrentTask::Status(status_bar) = &mut self.current_task { status_bar.set_failure(); } @@ -112,12 +109,13 @@ impl Component for TaskManager { fn render(&mut self, f: &mut Frame, rect: Rect) { match &mut self.current_task { - CurrentTask::AddMagnetBar(magnet_bar) => magnet_bar.render(f, rect), - CurrentTask::DeleteBar(delete_bar) => delete_bar.render(f, rect), - CurrentTask::MoveBar(move_bar) => move_bar.render(f, rect), - CurrentTask::FilterBar(filter_bar) => filter_bar.render(f, rect), + CurrentTask::AddMagnet(magnet_bar) => magnet_bar.render(f, rect), + CurrentTask::Delete(delete_bar) => delete_bar.render(f, rect), + CurrentTask::Move(move_bar) => move_bar.render(f, rect), + CurrentTask::Filter(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), + CurrentTask::ChangeCategory(category_bar) => category_bar.render(f, rect), } } @@ -128,29 +126,27 @@ impl Component for TaskManager { impl TaskManager { pub fn add_magnet(&mut self) { - self.current_task = CurrentTask::AddMagnetBar(AddMagnetBar::new(self.ctx.clone())); + self.current_task = CurrentTask::AddMagnet(tasks::AddMagnet::new(self.ctx.clone())); self.ctx.send_update_action(UpdateAction::SwitchToInputMode); } - pub fn search(&mut self, filter: &Option) { - self.current_task = CurrentTask::FilterBar(FilterBar::new(self.ctx.clone(), filter)); + pub fn search(&mut self, current_pattern: &Option) { + self.current_task = + CurrentTask::Filter(tasks::Filter::new(self.ctx.clone(), current_pattern)); self.ctx.send_update_action(UpdateAction::SwitchToInputMode); } - pub fn delete_torrent(&mut self, torrent: &RustmissionTorrent, mode: delete_torrent::Mode) { - self.current_task = CurrentTask::DeleteBar(DeleteBar::new( + pub fn delete_torrent(&mut self, torrent: &RustmissionTorrent, delete_with_files: bool) { + self.current_task = CurrentTask::Delete(tasks::Delete::new( self.ctx.clone(), - vec![TorrentInfo { - id: torrent.id.clone(), - name: torrent.torrent_name.clone(), - }], - mode, + vec![torrent.clone()], + delete_with_files, )); self.ctx.send_update_action(UpdateAction::SwitchToInputMode); } pub fn move_torrent(&mut self, torrent: &RustmissionTorrent) { - self.current_task = CurrentTask::MoveBar(MoveBar::new( + self.current_task = CurrentTask::Move(tasks::Move::new( self.ctx.clone(), vec![torrent.id.clone()], torrent.download_dir.to_string(), @@ -158,8 +154,16 @@ impl TaskManager { self.ctx.send_update_action(UpdateAction::SwitchToInputMode); } + pub fn change_category(&mut self, torrent: &RustmissionTorrent) { + self.current_task = CurrentTask::ChangeCategory(tasks::ChangeCategory::new( + self.ctx.clone(), + vec![torrent.id.clone()], + )); + self.ctx.send_update_action(UpdateAction::SwitchToInputMode); + } + fn success_task(&mut self, task: StatusTask) { - self.current_task = CurrentTask::Status(StatusBar::new( + self.current_task = CurrentTask::Status(tasks::Status::new( self.ctx.clone(), task, CurrentTaskState::Success(Instant::now()), @@ -172,7 +176,7 @@ impl TaskManager { } let state = ThrobberState::default(); - self.current_task = CurrentTask::Status(StatusBar::new( + self.current_task = CurrentTask::Status(tasks::Status::new( self.ctx.clone(), task, CurrentTaskState::Loading(state), @@ -186,7 +190,7 @@ impl TaskManager { return; } - self.current_task = CurrentTask::Default(DefaultBar::new()); + self.current_task = CurrentTask::Default(tasks::Default::new()); self.ctx .send_update_action(UpdateAction::SwitchToNormalMode); } diff --git a/rm-main/src/tui/tabs/torrents/tasks/add_magnet.rs b/rm-main/src/tui/tabs/torrents/tasks/add_magnet.rs index 60da468..e230611 100644 --- a/rm-main/src/tui/tabs/torrents/tasks/add_magnet.rs +++ b/rm-main/src/tui/tabs/torrents/tasks/add_magnet.rs @@ -1,5 +1,6 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::prelude::*; +use rm_config::CONFIG; use crate::{ transmission::TorrentAction, @@ -13,41 +14,65 @@ use rm_shared::{ status_task::StatusTask, }; -pub struct AddMagnetBar { +pub struct AddMagnet { input_magnet_mgr: InputManager, + input_category_mgr: InputManager, input_location_mgr: InputManager, stage: Stage, ctx: app::Ctx, } enum Stage { - AskMagnet, - AskLocation, + Magnet, + Category, + Location, } -impl AddMagnetBar { +const MAGNET_PROMPT: &str = "Add magnet URI: "; +const CATEGORY_PROMPT: &str = "Category (empty for default): "; +const LOCATION_PROMPT: &str = "Directory: "; + +impl AddMagnet { pub fn new(ctx: app::Ctx) -> Self { Self { - input_magnet_mgr: InputManager::new("Add (Magnet URL / Torrent path): ".to_string()), + input_magnet_mgr: InputManager::new(MAGNET_PROMPT.to_string()), + input_category_mgr: InputManager::new(CATEGORY_PROMPT.to_string()) + .autocompletions(CONFIG.categories.map.keys().cloned().collect()), input_location_mgr: InputManager::new_with_value( - "Directory: ".to_string(), + LOCATION_PROMPT.to_string(), ctx.session_info.download_dir.clone(), ), - stage: Stage::AskMagnet, + stage: Stage::Magnet, ctx, } } + pub fn magnet(mut self, magnet: impl Into) -> Self { + self.input_magnet_mgr.set_text(magnet); + if CONFIG.categories.is_empty() { + self.stage = Stage::Location + } else { + self.stage = Stage::Category; + } + + self + } + fn handle_input(&mut self, input: KeyEvent) -> ComponentAction { match self.stage { - Stage::AskMagnet => self.handle_magnet_input(input), - Stage::AskLocation => self.handle_location_input(input), + Stage::Magnet => self.handle_magnet_input(input), + Stage::Category => self.handle_category_input(input), + Stage::Location => self.handle_location_input(input), } } fn handle_magnet_input(&mut self, input: KeyEvent) -> ComponentAction { if input.code == KeyCode::Enter { - self.stage = Stage::AskLocation; + if CONFIG.categories.is_empty() { + self.stage = Stage::Location; + } else { + self.stage = Stage::Category; + } self.ctx.send_action(Action::Render); return ComponentAction::Nothing; } @@ -63,16 +88,61 @@ impl AddMagnetBar { ComponentAction::Nothing } + fn handle_category_input(&mut self, input: KeyEvent) -> ComponentAction { + if input.code == KeyCode::Enter { + if self.input_category_mgr.text().is_empty() { + self.stage = Stage::Location; + self.ctx.send_action(Action::Render); + return ComponentAction::Nothing; + } else if let Some(category) = + CONFIG.categories.map.get(&self.input_category_mgr.text()) + { + self.input_location_mgr = InputManager::new_with_value( + LOCATION_PROMPT.to_string(), + category.default_dir.to_string(), + ); + self.stage = Stage::Location; + self.ctx.send_action(Action::Render); + return ComponentAction::Nothing; + } else { + self.input_category_mgr.set_prompt(format!( + "Category ({} not found): ", + self.input_category_mgr.text() + )); + self.ctx.send_action(Action::Render); + return ComponentAction::Nothing; + }; + } + + if input.code == KeyCode::Esc { + return ComponentAction::Quit; + } + + if self.input_category_mgr.handle_key(input).is_some() { + self.ctx.send_action(Action::Render); + } + + ComponentAction::Nothing + } + fn handle_location_input(&mut self, input: KeyEvent) -> ComponentAction { if input.code == KeyCode::Enter { + let category = if self.input_category_mgr.text().is_empty() { + None + } else { + Some(self.input_category_mgr.text()) + }; + let torrent_action = TorrentAction::Add( self.input_magnet_mgr.text(), Some(self.input_location_mgr.text()), + category, ); self.ctx.send_torrent_action(torrent_action); let task = StatusTask::new_add(self.input_magnet_mgr.text()); - self.ctx.send_update_action(UpdateAction::TaskSet(task)); + self.ctx + .send_update_action(UpdateAction::StatusTaskSet(task)); ComponentAction::Quit } else if input.code == KeyCode::Esc { @@ -86,7 +156,7 @@ impl AddMagnetBar { } } -impl Component for AddMagnetBar { +impl Component for AddMagnet { #[must_use] fn handle_actions(&mut self, action: Action) -> ComponentAction { match action { @@ -97,8 +167,9 @@ impl Component for AddMagnetBar { fn render(&mut self, f: &mut Frame, rect: Rect) { match self.stage { - Stage::AskMagnet => self.input_magnet_mgr.render(f, rect), - Stage::AskLocation => self.input_location_mgr.render(f, rect), + Stage::Magnet => self.input_magnet_mgr.render(f, rect), + Stage::Category => self.input_category_mgr.render(f, rect), + Stage::Location => self.input_location_mgr.render(f, rect), } } } diff --git a/rm-main/src/tui/tabs/torrents/tasks/change_category.rs b/rm-main/src/tui/tabs/torrents/tasks/change_category.rs new file mode 100644 index 0000000..a4efc4a --- /dev/null +++ b/rm-main/src/tui/tabs/torrents/tasks/change_category.rs @@ -0,0 +1,72 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::prelude::*; +use rm_config::CONFIG; +use rm_shared::{ + action::{Action, UpdateAction}, + status_task::StatusTask, +}; +use transmission_rpc::types::Id; + +use crate::tui::{ + app, + components::{Component, ComponentAction, InputManager}, +}; + +pub struct ChangeCategory { + torrents_to_change: Vec, + ctx: app::Ctx, + input_mgr: InputManager, +} + +impl ChangeCategory { + pub fn new(ctx: app::Ctx, torrents_to_change: Vec) -> Self { + let prompt = "New category: ".to_string(); + + Self { + torrents_to_change, + input_mgr: InputManager::new(prompt) + .autocompletions(CONFIG.categories.map.keys().cloned().collect()), + ctx, + } + } + + fn handle_input(&mut self, input: KeyEvent) -> ComponentAction { + if input.code == KeyCode::Enter { + let category = self.input_mgr.text(); + self.ctx + .send_torrent_action(crate::transmission::TorrentAction::ChangeCategory( + self.torrents_to_change.clone(), + category.clone(), + )); + + let task = StatusTask::new_category(category); + self.ctx + .send_update_action(UpdateAction::StatusTaskSet(task)); + return ComponentAction::Quit; + } + + if input.code == KeyCode::Esc { + return ComponentAction::Quit; + } + + if self.input_mgr.handle_key(input).is_some() { + self.ctx.send_action(Action::Render); + } + + ComponentAction::Nothing + } +} + +impl Component for ChangeCategory { + #[must_use] + fn handle_actions(&mut self, action: Action) -> ComponentAction { + match action { + Action::Input(input) => self.handle_input(input), + _ => ComponentAction::Nothing, + } + } + + fn render(&mut self, f: &mut Frame, rect: Rect) { + self.input_mgr.render(f, rect); + } +} diff --git a/rm-main/src/tui/tabs/torrents/tasks/default.rs b/rm-main/src/tui/tabs/torrents/tasks/default.rs index 64be209..2c8bcc3 100644 --- a/rm-main/src/tui/tabs/torrents/tasks/default.rs +++ b/rm-main/src/tui/tabs/torrents/tasks/default.rs @@ -4,15 +4,15 @@ use rm_shared::action::Action; use crate::tui::components::Component; -pub struct DefaultBar {} +pub struct Default {} -impl DefaultBar { +impl Default { pub const fn new() -> Self { Self {} } } -impl Component for DefaultBar { +impl Component for Default { fn render(&mut self, f: &mut ratatui::Frame<'_>, rect: Rect) { if CONFIG.general.beginner_mode { if let Some(keys) = CONFIG.keybindings.get_keys_for_action(Action::ShowHelp) { diff --git a/rm-main/src/tui/tabs/torrents/tasks/delete_torrent.rs b/rm-main/src/tui/tabs/torrents/tasks/delete_torrent.rs index 295f577..772ba8c 100644 --- a/rm-main/src/tui/tabs/torrents/tasks/delete_torrent.rs +++ b/rm-main/src/tui/tabs/torrents/tasks/delete_torrent.rs @@ -5,41 +5,30 @@ use transmission_rpc::types::Id; use crate::transmission::TorrentAction; use crate::tui::app; use crate::tui::components::{Component, ComponentAction, InputManager}; +use crate::tui::tabs::torrents::rustmission_torrent::RustmissionTorrent; use rm_shared::action::{Action, UpdateAction}; use rm_shared::status_task::StatusTask; -#[derive(Clone)] -pub struct TorrentInfo { - pub id: Id, - pub name: String, -} - -pub struct DeleteBar { - torrents_to_delete: Vec, +pub struct Delete { + torrents_to_delete: Vec, ctx: app::Ctx, input_mgr: InputManager, - mode: Mode, + delete_with_files: bool, } -pub enum Mode { - WithFiles, - WithoutFiles, -} - -impl DeleteBar { - 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(), - Mode::WithoutFiles => "Really delete selected without files? (y/n) ".to_string(), - } +impl Delete { + pub fn new(ctx: app::Ctx, to_delete: Vec, delete_with_files: bool) -> Self { + let prompt = if delete_with_files { + "Really delete selected WITH files? (y/n) ".to_string() + } else { + "Really delete selected without files? (y/n) ".to_string() }; Self { torrents_to_delete: to_delete, input_mgr: InputManager::new(prompt), ctx, - mode, + delete_with_files, } } @@ -49,21 +38,21 @@ impl DeleteBar { .iter() .map(|x| x.id.clone()) .collect(); - match self.mode { - Mode::WithFiles => self - .ctx - .send_torrent_action(TorrentAction::DelWithFiles(torrents_to_delete)), - Mode::WithoutFiles => self - .ctx - .send_torrent_action(TorrentAction::DelWithoutFiles(torrents_to_delete)), + if self.delete_with_files { + self.ctx + .send_torrent_action(TorrentAction::DelWithFiles(torrents_to_delete)) + } else { + self.ctx + .send_torrent_action(TorrentAction::DelWithoutFiles(torrents_to_delete)) } - let task = StatusTask::new_del(self.torrents_to_delete[0].name.clone()); - self.ctx.send_update_action(UpdateAction::TaskSet(task)); + let task = StatusTask::new_del(self.torrents_to_delete[0].torrent_name.clone()); + self.ctx + .send_update_action(UpdateAction::StatusTaskSet(task)); } } -impl Component for DeleteBar { +impl Component for Delete { fn handle_actions(&mut self, action: Action) -> ComponentAction { match action { Action::Input(input) => { diff --git a/rm-main/src/tui/tabs/torrents/tasks/filter.rs b/rm-main/src/tui/tabs/torrents/tasks/filter.rs index d0a0bb8..3114fa5 100644 --- a/rm-main/src/tui/tabs/torrents/tasks/filter.rs +++ b/rm-main/src/tui/tabs/torrents/tasks/filter.rs @@ -6,30 +6,22 @@ use rm_shared::action::{Action, UpdateAction}; use crate::tui::{ app, components::{Component, ComponentAction, InputManager}, - tabs::torrents::table_manager::Filter, }; -pub struct FilterBar { +pub struct Filter { ctx: app::Ctx, input: InputManager, } -impl FilterBar { - pub fn new(ctx: app::Ctx, current_filter: &Option) -> Self { - let filter = { - if let Some(current_filter) = current_filter { - current_filter.pattern.clone() - } else { - "".to_string() - } - }; - - let input = InputManager::new_with_value("Search: ".to_string(), filter); +impl Filter { + pub fn new(ctx: app::Ctx, current_pattern: &Option) -> Self { + let pattern = current_pattern.as_ref().cloned().unwrap_or_default(); + let input = InputManager::new_with_value("Search: ".to_string(), pattern); Self { ctx, input } } } -impl Component for FilterBar { +impl Component for Filter { fn handle_actions(&mut self, action: Action) -> ComponentAction { match action { Action::Input(input) => { diff --git a/rm-main/src/tui/tabs/torrents/tasks/mod.rs b/rm-main/src/tui/tabs/torrents/tasks/mod.rs index a00c00a..5c27c0f 100644 --- a/rm-main/src/tui/tabs/torrents/tasks/mod.rs +++ b/rm-main/src/tui/tabs/torrents/tasks/mod.rs @@ -1,6 +1,15 @@ -pub mod add_magnet; -pub mod default; -pub mod delete_torrent; -pub mod filter; -pub mod move_torrent; -pub mod status; +mod add_magnet; +mod change_category; +mod default; +mod delete_torrent; +mod filter; +mod move_torrent; +mod status; + +pub use add_magnet::AddMagnet; +pub use change_category::ChangeCategory; +pub use default::Default; +pub use delete_torrent::Delete; +pub use filter::Filter; +pub use move_torrent::Move; +pub use status::{CurrentTaskState, Status}; diff --git a/rm-main/src/tui/tabs/torrents/tasks/move_torrent.rs b/rm-main/src/tui/tabs/torrents/tasks/move_torrent.rs index 0495afe..e4102bd 100644 --- a/rm-main/src/tui/tabs/torrents/tasks/move_torrent.rs +++ b/rm-main/src/tui/tabs/torrents/tasks/move_torrent.rs @@ -14,13 +14,13 @@ use crate::{ }, }; -pub struct MoveBar { +pub struct Move { torrents_to_move: Vec, ctx: app::Ctx, input_mgr: InputManager, } -impl MoveBar { +impl Move { pub fn new(ctx: app::Ctx, torrents_to_move: Vec, existing_location: String) -> Self { let prompt = "New directory: ".to_string(); @@ -40,7 +40,8 @@ impl MoveBar { self.ctx.send_torrent_action(torrent_action); let task = StatusTask::new_move(new_location); - self.ctx.send_update_action(UpdateAction::TaskSet(task)); + self.ctx + .send_update_action(UpdateAction::StatusTaskSet(task)); ComponentAction::Quit } else if input.code == KeyCode::Esc { @@ -54,7 +55,7 @@ impl MoveBar { } } -impl Component for MoveBar { +impl Component for Move { fn handle_actions(&mut self, action: Action) -> ComponentAction { match action { Action::Input(input) => self.handle_input(input), diff --git a/rm-main/src/tui/tabs/torrents/tasks/status.rs b/rm-main/src/tui/tabs/torrents/tasks/status.rs index 1a124eb..d859d15 100644 --- a/rm-main/src/tui/tabs/torrents/tasks/status.rs +++ b/rm-main/src/tui/tabs/torrents/tasks/status.rs @@ -8,7 +8,7 @@ use tokio::time::{self, Instant}; use crate::tui::{app, components::Component}; -pub struct StatusBar { +pub struct Status { task: StatusTask, pub task_status: CurrentTaskState, ctx: app::Ctx, @@ -21,7 +21,7 @@ pub enum CurrentTaskState { Failure(Instant), } -impl StatusBar { +impl Status { pub const fn new(ctx: app::Ctx, task: StatusTask, task_status: CurrentTaskState) -> Self { Self { task, @@ -39,7 +39,7 @@ impl StatusBar { } } -impl Component for StatusBar { +impl Component for Status { fn render(&mut self, f: &mut Frame, rect: Rect) { match &mut self.task_status { CurrentTaskState::Loading(ref mut state) => { @@ -68,7 +68,7 @@ impl Component for StatusBar { fn handle_update_action(&mut self, action: UpdateAction) { match action { - UpdateAction::TaskSuccess => { + UpdateAction::StatusTaskSuccess => { self.set_success(); self.ctx.send_action(Action::Render); } @@ -89,13 +89,13 @@ impl Component for StatusBar { CurrentTaskState::Success(start) => { let expiration_duration = time::Duration::from_secs(5); if start.elapsed() >= expiration_duration { - self.ctx.send_update_action(UpdateAction::TaskClear); + self.ctx.send_update_action(UpdateAction::StatusTaskClear); } } CurrentTaskState::Failure(start) => { let expiration_duration = time::Duration::from_secs(5); if start.elapsed() >= expiration_duration { - self.ctx.send_update_action(UpdateAction::TaskClear); + self.ctx.send_update_action(UpdateAction::StatusTaskClear); } } } diff --git a/rm-shared/src/action.rs b/rm-shared/src/action.rs index 02b37c3..411af35 100644 --- a/rm-shared/src/action.rs +++ b/rm-shared/src/action.rs @@ -39,6 +39,7 @@ pub enum Action { DeleteWithFiles, AddMagnet, MoveTorrent, + ChangeCategory, // Search Tab ShowProvidersInfo, } @@ -49,11 +50,6 @@ pub enum UpdateAction { SwitchToNormalMode, Error(Box), // Torrents Tab - TaskClear, - TaskSuccess, - TaskFailure, - TaskSet(StatusTask), - TaskSetSuccess(StatusTask), SessionStats(Arc), FreeSpace(Arc), UpdateTorrents(Vec), @@ -65,6 +61,12 @@ pub enum UpdateAction { ProviderResult(MagneteaseResult), ProviderError(MagneteaseError), SearchFinished, + // Task Manager's Status Task + StatusTaskClear, + StatusTaskSuccess, + StatusTaskFailure, + StatusTaskSet(StatusTask), + StatusTaskSetSuccess(StatusTask), } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/rm-shared/src/header.rs b/rm-shared/src/header.rs index 141a29e..d607f47 100644 --- a/rm-shared/src/header.rs +++ b/rm-shared/src/header.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Hash, PartialEq, Eq, Clone, Copy)] pub enum Header { + Id, Name, SizeWhenDone, Progress, @@ -13,11 +14,12 @@ pub enum Header { Padding, UploadRatio, UploadedEver, - Id, ActivityDate, AddedDate, PeersConnected, SmallStatus, + Category, + CategoryIcon, } impl Header { @@ -38,6 +40,8 @@ impl Header { Self::AddedDate => Constraint::Length(12), Self::PeersConnected => Constraint::Length(6), Self::SmallStatus => Constraint::Length(1), + Self::Category => Constraint::Max(15), + Self::CategoryIcon => Constraint::Length(5), } } @@ -58,6 +62,8 @@ impl Header { Self::AddedDate => "Added", Self::PeersConnected => "Peers", Self::SmallStatus => "", + Self::Category => "Category", + Self::CategoryIcon => "", } } } diff --git a/rm-shared/src/status_task.rs b/rm-shared/src/status_task.rs index 6505cdc..39b273d 100644 --- a/rm-shared/src/status_task.rs +++ b/rm-shared/src/status_task.rs @@ -11,6 +11,7 @@ enum TaskType { Delete, Move, Open, + ChangeCategory, } impl StatusTask { @@ -35,6 +36,13 @@ impl StatusTask { } } + pub fn new_category(what: impl Into) -> Self { + StatusTask { + task_type: TaskType::ChangeCategory, + what: what.into(), + } + } + pub fn new_open(what: impl Into) -> Self { StatusTask { task_type: TaskType::Open, @@ -50,6 +58,13 @@ impl StatusTask { TaskType::Delete => format!("Deleted {truncated}"), TaskType::Move => format!("Moved {truncated}"), TaskType::Open => format!("Opened {truncated}"), + TaskType::ChangeCategory => { + if truncated.is_empty() { + format!("Categories cleared!") + } else { + format!("Category set to {truncated}!") + } + } } } @@ -61,6 +76,7 @@ impl StatusTask { TaskType::Delete => format!("Error deleting {truncated}"), TaskType::Move => format!("Error moving to {truncated}"), TaskType::Open => format!("Error opening {truncated}"), + TaskType::ChangeCategory => format!("Error changing category to {truncated}"), } } @@ -72,6 +88,7 @@ impl StatusTask { TaskType::Delete => format!("Deleting {truncated}"), TaskType::Move => format!("Moving {truncated}"), TaskType::Open => format!("Opening {truncated}"), + TaskType::ChangeCategory => format!("Changing category to {truncated}"), } } }