diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index a8632c2..19b6624 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -9,12 +9,14 @@ macro_rules! generate_keybindings { } generate_keybindings! { - quit, up, down, left, right, + backspace, + search, submit, + quit, esc } @@ -24,10 +26,6 @@ pub struct KeyBinding { } pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { - quit: KeyBinding { - key: Key::Char('q'), - desc: "Quit", - }, up: KeyBinding { key: Key::Up, desc: "Scroll up", @@ -44,10 +42,22 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { key: Key::Right, desc: "Move right", }, + backspace: KeyBinding { + key: Key::Backspace, + desc: "Backspace", + }, + search: KeyBinding { + key: Key::Char('s'), + desc: "Search", + }, submit: KeyBinding { key: Key::Enter, desc: "Select", }, + quit: KeyBinding { + key: Key::Char('q'), + desc: "Quit", + }, esc: KeyBinding { key: Key::Esc, desc: "Exit current menu", diff --git a/src/app/radarr.rs b/src/app/radarr.rs index c22ea89..0a9d689 100644 --- a/src/app/radarr.rs +++ b/src/app/radarr.rs @@ -29,6 +29,8 @@ pub struct RadarrData { pub collection_movies: StatefulTable, pub main_tabs: TabState, pub movie_info_tabs: TabState, + pub search: String, + pub is_searching: bool, } impl RadarrData { @@ -70,11 +72,14 @@ impl Default for RadarrData { movie_crew: StatefulTable::default(), collections: StatefulTable::default(), collection_movies: StatefulTable::default(), + search: String::default(), + is_searching: false, main_tabs: TabState::new(vec![ TabRoute { title: "Library".to_owned(), route: ActiveRadarrBlock::Movies.into(), - help: "<↑↓> scroll table | movie details | ←→ change tab ".to_owned(), + help: "<↑↓> scroll table | search | movie details | ←→ change tab " + .to_owned(), }, TabRoute { title: "Downloads".to_owned(), diff --git a/src/event/key.rs b/src/event/key.rs index d5c1a78..ca95a30 100644 --- a/src/event/key.rs +++ b/src/event/key.rs @@ -11,6 +11,7 @@ pub enum Key { Right, Enter, Esc, + Backspace, Char(char), Unknown, } @@ -42,6 +43,10 @@ impl From for Key { code: KeyCode::Right, .. } => Key::Right, + KeyEvent { + code: KeyCode::Backspace, + .. + } => Key::Backspace, KeyEvent { code: KeyCode::Enter, .. diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 9c001c4..bad0d41 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,20 +1,51 @@ +use radarr_handlers::RadarrHandler; + +use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; -use crate::handlers::radarr_handler::handle_radarr_key_events; use crate::models::{HorizontallyScrollableText, Route}; -mod radarr_handler; +mod radarr_handlers; -pub async fn handle_key_events(key: Key, app: &mut App) { +pub trait KeyEventHandler<'a, T: Into> { + fn handle_key_event(&mut self) { + let key = self.get_key(); + match key { + _ if *key == DEFAULT_KEYBINDINGS.up.key => self.handle_scroll_up(), + _ if *key == DEFAULT_KEYBINDINGS.down.key => self.handle_scroll_down(), + _ if *key == DEFAULT_KEYBINDINGS.left.key || *key == DEFAULT_KEYBINDINGS.right.key => { + self.handle_tab_action() + } + _ if *key == DEFAULT_KEYBINDINGS.submit.key => self.handle_submit(), + _ if *key == DEFAULT_KEYBINDINGS.esc.key => self.handle_esc(), + _ => self.handle_char_key_event(), + } + } + + fn handle(&mut self) { + self.handle_key_event(); + } + + fn with(key: &'a Key, app: &'a mut App, active_block: &'a T) -> Self; + fn get_key(&self) -> &Key; + fn handle_scroll_up(&mut self); + fn handle_scroll_down(&mut self); + fn handle_tab_action(&mut self); + fn handle_submit(&mut self); + fn handle_esc(&mut self); + fn handle_char_key_event(&mut self); +} + +pub fn handle_events(key: Key, app: &mut App) { match app.get_current_route().clone() { Route::Radarr(active_radarr_block) => { - handle_radarr_key_events(key, app, active_radarr_block).await + RadarrHandler::with(&key, app, &active_radarr_block).handle() } _ => (), } } -pub async fn handle_clear_errors(app: &mut App) { +pub fn handle_clear_errors(app: &mut App) { if !app.error.text.is_empty() { app.error = HorizontallyScrollableText::default(); } diff --git a/src/handlers/radarr_handler.rs b/src/handlers/radarr_handler.rs deleted file mode 100644 index 65e7bbe..0000000 --- a/src/handlers/radarr_handler.rs +++ /dev/null @@ -1,134 +0,0 @@ -use crate::app::key_binding::DEFAULT_KEYBINDINGS; -use crate::app::radarr::ActiveRadarrBlock; -use crate::handlers::handle_clear_errors; -use crate::models::Scrollable; -use crate::{App, Key}; - -pub async fn handle_radarr_key_events( - key: Key, - app: &mut App, - active_radarr_block: ActiveRadarrBlock, -) { - match key { - _ if key == DEFAULT_KEYBINDINGS.up.key => handle_scroll_up(app, active_radarr_block).await, - _ if key == DEFAULT_KEYBINDINGS.down.key => handle_scroll_down(app, active_radarr_block).await, - _ if key == DEFAULT_KEYBINDINGS.left.key || key == DEFAULT_KEYBINDINGS.right.key => { - handle_tab_action(key, app, active_radarr_block).await - } - _ if key == DEFAULT_KEYBINDINGS.submit.key => handle_submit(app, active_radarr_block).await, - _ if key == DEFAULT_KEYBINDINGS.esc.key => handle_esc(app, active_radarr_block).await, - _ => (), - } -} - -async fn handle_tab_action(key: Key, app: &mut App, active_radarr_block: ActiveRadarrBlock) { - match active_radarr_block { - ActiveRadarrBlock::Movies | ActiveRadarrBlock::Downloads | ActiveRadarrBlock::Collections => { - match key { - _ if key == DEFAULT_KEYBINDINGS.left.key => { - app.data.radarr_data.main_tabs.previous(); - app.pop_and_push_navigation_stack( - app.data.radarr_data.main_tabs.get_active_route().clone(), - ); - } - _ if key == DEFAULT_KEYBINDINGS.right.key => { - app.data.radarr_data.main_tabs.next(); - app.pop_and_push_navigation_stack( - app.data.radarr_data.main_tabs.get_active_route().clone(), - ); - } - _ => (), - } - } - ActiveRadarrBlock::MovieDetails - | ActiveRadarrBlock::MovieHistory - | ActiveRadarrBlock::FileInfo - | ActiveRadarrBlock::Cast - | ActiveRadarrBlock::Crew => match key { - _ if key == DEFAULT_KEYBINDINGS.left.key => { - app.data.radarr_data.movie_info_tabs.previous(); - app.pop_and_push_navigation_stack( - app - .data - .radarr_data - .movie_info_tabs - .get_active_route() - .clone(), - ); - } - _ if key == DEFAULT_KEYBINDINGS.right.key => { - app.data.radarr_data.movie_info_tabs.next(); - app.pop_and_push_navigation_stack( - app - .data - .radarr_data - .movie_info_tabs - .get_active_route() - .clone(), - ); - } - _ => (), - }, - _ => (), - } -} - -async fn handle_scroll_up(app: &mut App, active_radarr_block: ActiveRadarrBlock) { - match active_radarr_block { - ActiveRadarrBlock::Collections => app.data.radarr_data.collections.scroll_up(), - ActiveRadarrBlock::CollectionDetails => app.data.radarr_data.collection_movies.scroll_up(), - ActiveRadarrBlock::Movies => app.data.radarr_data.movies.scroll_up(), - ActiveRadarrBlock::MovieDetails => app.data.radarr_data.movie_details.scroll_up(), - ActiveRadarrBlock::MovieHistory => app.data.radarr_data.movie_history.scroll_up(), - ActiveRadarrBlock::Cast => app.data.radarr_data.movie_cast.scroll_up(), - ActiveRadarrBlock::Crew => app.data.radarr_data.movie_crew.scroll_up(), - ActiveRadarrBlock::Downloads => app.data.radarr_data.downloads.scroll_up(), - _ => (), - } -} - -async fn handle_scroll_down(app: &mut App, active_radarr_block: ActiveRadarrBlock) { - match active_radarr_block { - ActiveRadarrBlock::Collections => app.data.radarr_data.collections.scroll_down(), - ActiveRadarrBlock::CollectionDetails => app.data.radarr_data.collection_movies.scroll_down(), - ActiveRadarrBlock::Movies => app.data.radarr_data.movies.scroll_down(), - ActiveRadarrBlock::MovieDetails => app.data.radarr_data.movie_details.scroll_down(), - ActiveRadarrBlock::MovieHistory => app.data.radarr_data.movie_history.scroll_down(), - ActiveRadarrBlock::Cast => app.data.radarr_data.movie_cast.scroll_down(), - ActiveRadarrBlock::Crew => app.data.radarr_data.movie_crew.scroll_down(), - ActiveRadarrBlock::Downloads => app.data.radarr_data.downloads.scroll_down(), - _ => (), - } -} - -async fn handle_submit(app: &mut App, active_radarr_block: ActiveRadarrBlock) { - match active_radarr_block { - ActiveRadarrBlock::Movies => app.push_navigation_stack(ActiveRadarrBlock::MovieDetails.into()), - ActiveRadarrBlock::Collections => { - app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into()) - } - ActiveRadarrBlock::CollectionDetails => { - app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()) - } - _ => (), - } -} - -async fn handle_esc(app: &mut App, active_radarr_block: ActiveRadarrBlock) { - match active_radarr_block { - ActiveRadarrBlock::MovieDetails - | ActiveRadarrBlock::MovieHistory - | ActiveRadarrBlock::FileInfo - | ActiveRadarrBlock::Cast - | ActiveRadarrBlock::Crew => { - app.pop_navigation_stack(); - app.data.radarr_data.reset_movie_info_tabs(); - } - ActiveRadarrBlock::CollectionDetails => { - app.pop_navigation_stack(); - app.data.radarr_data.reset_movie_collection_table(); - } - ActiveRadarrBlock::ViewMovieOverview => app.pop_navigation_stack(), - _ => handle_clear_errors(app).await, - } -} diff --git a/src/handlers/radarr_handlers/collection_details_handler.rs b/src/handlers/radarr_handlers/collection_details_handler.rs new file mode 100644 index 0000000..637ed7d --- /dev/null +++ b/src/handlers/radarr_handlers/collection_details_handler.rs @@ -0,0 +1,64 @@ +use crate::app::radarr::ActiveRadarrBlock; +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::models::Scrollable; + +pub(super) struct CollectionDetailsHandler<'a> { + key: &'a Key, + app: &'a mut App, + active_radarr_block: &'a ActiveRadarrBlock, +} + +impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for CollectionDetailsHandler<'a> { + fn with( + key: &'a Key, + app: &'a mut App, + active_block: &'a ActiveRadarrBlock, + ) -> CollectionDetailsHandler<'a> { + CollectionDetailsHandler { + key, + app, + active_radarr_block: active_block, + } + } + + fn get_key(&self) -> &Key { + self.key + } + + fn handle_scroll_up(&mut self) { + if ActiveRadarrBlock::CollectionDetails == *self.active_radarr_block { + self.app.data.radarr_data.collection_movies.scroll_up() + } + } + + fn handle_scroll_down(&mut self) { + if ActiveRadarrBlock::CollectionDetails == *self.active_radarr_block { + self.app.data.radarr_data.collection_movies.scroll_down() + } + } + + fn handle_tab_action(&mut self) {} + + fn handle_submit(&mut self) { + if ActiveRadarrBlock::CollectionDetails == *self.active_radarr_block { + self + .app + .push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()) + } + } + + fn handle_esc(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::CollectionDetails => { + self.app.pop_navigation_stack(); + self.app.data.radarr_data.reset_movie_collection_table(); + } + ActiveRadarrBlock::ViewMovieOverview => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_char_key_event(&mut self) {} +} diff --git a/src/handlers/radarr_handlers/mod.rs b/src/handlers/radarr_handlers/mod.rs new file mode 100644 index 0000000..71340e8 --- /dev/null +++ b/src/handlers/radarr_handlers/mod.rs @@ -0,0 +1,180 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::radarr::ActiveRadarrBlock; +use crate::handlers::radarr_handlers::collection_details_handler::CollectionDetailsHandler; +use crate::handlers::radarr_handlers::movie_details_handler::MovieDetailsHandler; +use crate::handlers::{handle_clear_errors, KeyEventHandler}; +use crate::models::Scrollable; +use crate::{App, Key}; + +mod collection_details_handler; +mod movie_details_handler; + +pub(super) struct RadarrHandler<'a> { + key: &'a Key, + app: &'a mut App, + active_radarr_block: &'a ActiveRadarrBlock, +} + +impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for RadarrHandler<'a> { + fn handle(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::MovieDetails + | ActiveRadarrBlock::MovieHistory + | ActiveRadarrBlock::FileInfo + | ActiveRadarrBlock::Cast + | ActiveRadarrBlock::Crew => { + MovieDetailsHandler::with(self.key, self.app, self.active_radarr_block).handle() + } + ActiveRadarrBlock::CollectionDetails | ActiveRadarrBlock::ViewMovieOverview => { + CollectionDetailsHandler::with(self.key, self.app, self.active_radarr_block).handle() + } + _ => self.handle_key_event(), + } + } + + fn with( + key: &'a Key, + app: &'a mut App, + active_block: &'a ActiveRadarrBlock, + ) -> RadarrHandler<'a> { + RadarrHandler { + key, + app, + active_radarr_block: active_block, + } + } + + fn get_key(&self) -> &Key { + self.key + } + + fn handle_scroll_up(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::Collections => self.app.data.radarr_data.collections.scroll_up(), + ActiveRadarrBlock::CollectionDetails => { + self.app.data.radarr_data.collection_movies.scroll_up() + } + ActiveRadarrBlock::Movies => self.app.data.radarr_data.movies.scroll_up(), + ActiveRadarrBlock::Downloads => self.app.data.radarr_data.downloads.scroll_up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::Collections => self.app.data.radarr_data.collections.scroll_down(), + ActiveRadarrBlock::CollectionDetails => { + self.app.data.radarr_data.collection_movies.scroll_down() + } + ActiveRadarrBlock::Movies => self.app.data.radarr_data.movies.scroll_down(), + ActiveRadarrBlock::Downloads => self.app.data.radarr_data.downloads.scroll_down(), + _ => (), + } + } + + fn handle_tab_action(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::Movies | ActiveRadarrBlock::Downloads | ActiveRadarrBlock::Collections => { + match self.key { + _ if *self.key == DEFAULT_KEYBINDINGS.left.key => { + self.app.data.radarr_data.main_tabs.previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .radarr_data + .main_tabs + .get_active_route() + .clone(), + ); + } + _ if *self.key == DEFAULT_KEYBINDINGS.right.key => { + self.app.data.radarr_data.main_tabs.next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .radarr_data + .main_tabs + .get_active_route() + .clone(), + ); + } + _ => (), + } + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::Movies => self + .app + .push_navigation_stack(ActiveRadarrBlock::MovieDetails.into()), + ActiveRadarrBlock::Collections => self + .app + .push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into()), + ActiveRadarrBlock::SearchMovie => { + let search_string = self + .app + .data + .radarr_data + .search + .drain(..) + .collect::() + .to_lowercase(); + let movie_index = self + .app + .data + .radarr_data + .movies + .items + .iter() + .position(|movie| movie.title.to_lowercase() == search_string); + + self.app.data.radarr_data.is_searching = false; + self.app.data.radarr_data.movies.select_index(movie_index); + + if movie_index.is_some() { + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::SearchMovie => { + self.app.pop_navigation_stack(); + self.app.data.radarr_data.is_searching = false; + self.app.data.radarr_data.search = String::default(); + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match *self.active_radarr_block { + ActiveRadarrBlock::Movies => match key { + _ if *key == DEFAULT_KEYBINDINGS.search.key => { + self + .app + .push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); + self.app.data.radarr_data.is_searching = true; + } + _ => (), + }, + ActiveRadarrBlock::SearchMovie => match key { + _ if *key == DEFAULT_KEYBINDINGS.backspace.key => { + self.app.data.radarr_data.search.pop(); + } + Key::Char(character) => self.app.data.radarr_data.search.push(*character), + _ => (), + }, + _ => {} + } + } +} diff --git a/src/handlers/radarr_handlers/movie_details_handler.rs b/src/handlers/radarr_handlers/movie_details_handler.rs new file mode 100644 index 0000000..384e76d --- /dev/null +++ b/src/handlers/radarr_handlers/movie_details_handler.rs @@ -0,0 +1,105 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::radarr::ActiveRadarrBlock; +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::models::Scrollable; + +pub(super) struct MovieDetailsHandler<'a> { + key: &'a Key, + app: &'a mut App, + active_radarr_block: &'a ActiveRadarrBlock, +} + +impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for MovieDetailsHandler<'a> { + fn with( + key: &'a Key, + app: &'a mut App, + active_block: &'a ActiveRadarrBlock, + ) -> MovieDetailsHandler<'a> { + MovieDetailsHandler { + key, + app, + active_radarr_block: active_block, + } + } + + fn get_key(&self) -> &Key { + self.key + } + + fn handle_scroll_up(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::MovieDetails => self.app.data.radarr_data.movie_details.scroll_up(), + ActiveRadarrBlock::MovieHistory => self.app.data.radarr_data.movie_history.scroll_up(), + ActiveRadarrBlock::Cast => self.app.data.radarr_data.movie_cast.scroll_up(), + ActiveRadarrBlock::Crew => self.app.data.radarr_data.movie_crew.scroll_up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::MovieDetails => self.app.data.radarr_data.movie_details.scroll_down(), + ActiveRadarrBlock::MovieHistory => self.app.data.radarr_data.movie_history.scroll_down(), + ActiveRadarrBlock::Cast => self.app.data.radarr_data.movie_cast.scroll_down(), + ActiveRadarrBlock::Crew => self.app.data.radarr_data.movie_crew.scroll_down(), + _ => (), + } + } + + fn handle_tab_action(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::MovieDetails + | ActiveRadarrBlock::MovieHistory + | ActiveRadarrBlock::FileInfo + | ActiveRadarrBlock::Cast + | ActiveRadarrBlock::Crew => match self.key { + _ if *self.key == DEFAULT_KEYBINDINGS.left.key => { + self.app.data.radarr_data.movie_info_tabs.previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .radarr_data + .movie_info_tabs + .get_active_route() + .clone(), + ); + } + _ if *self.key == DEFAULT_KEYBINDINGS.right.key => { + self.app.data.radarr_data.movie_info_tabs.next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .radarr_data + .movie_info_tabs + .get_active_route() + .clone(), + ); + } + _ => (), + }, + _ => (), + } + } + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::MovieDetails + | ActiveRadarrBlock::MovieHistory + | ActiveRadarrBlock::FileInfo + | ActiveRadarrBlock::Cast + | ActiveRadarrBlock::Crew => { + self.app.pop_navigation_stack(); + self.app.data.radarr_data.reset_movie_info_tabs(); + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) {} +} diff --git a/src/main.rs b/src/main.rs index ffde32e..78c8eba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,7 +87,7 @@ async fn start_ui(app: &Arc>) -> Result<()> { break; } - handlers::handle_key_events(key, &mut app).await; + handlers::handle_events(key, &mut app); } InputEvent::Tick => app.on_tick(is_first_render).await, diff --git a/src/models/mod.rs b/src/models/mod.rs index ed9006a..2d01b87 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -58,6 +58,10 @@ impl StatefulTable { pub fn current_selection_clone(&self) -> T { self.items[self.state.selected().unwrap_or(0)].clone() } + + pub fn select_index(&mut self, index: Option) { + self.state.select(index); + } } impl Scrollable for StatefulTable { diff --git a/src/ui/radarr_ui.rs b/src/ui/radarr_ui.rs deleted file mode 100644 index 86d5296..0000000 --- a/src/ui/radarr_ui.rs +++ /dev/null @@ -1,783 +0,0 @@ -use std::iter; -use std::ops::Sub; - -use chrono::{Duration, Utc}; -use tui::backend::Backend; -use tui::layout::{Alignment, Constraint, Rect}; -use tui::style::{Color, Style}; -use tui::text::{Spans, Text}; -use tui::widgets::{Block, Cell, Paragraph, Row, Wrap}; -use tui::Frame; - -use crate::app::radarr::{ActiveRadarrBlock, RadarrData}; -use crate::app::App; -use crate::logos::RADARR_LOGO; -use crate::models::radarr_models::{Credit, DiskSpace, DownloadRecord, Movie, MovieHistoryItem}; -use crate::models::Route; -use crate::ui::utils::{ - borderless_block, horizontal_chunks, horizontal_chunks_with_margin, layout_block_bottom_border, - layout_block_top_border, layout_block_top_border_with_title, layout_block_with_title, - line_gauge_with_label, line_gauge_with_title, spans_info_default, spans_info_primary, style_bold, - style_default, style_failure, style_help, style_primary, style_success, style_warning, - title_block, title_style, vertical_chunks, vertical_chunks_with_margin, -}; -use crate::ui::{ - draw_large_popup_over, draw_small_popup_over, draw_table, draw_tabs, loading, TableProps, -}; -use crate::utils::{convert_runtime, convert_to_gb}; - -pub(super) fn draw_radarr_ui(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { - let (content_rect, _) = draw_tabs(f, area, " Movies ", &app.data.radarr_data.main_tabs); - - if let Route::Radarr(active_radarr_block) = app.get_current_route().clone() { - match active_radarr_block { - ActiveRadarrBlock::Movies => draw_library(f, app, content_rect), - ActiveRadarrBlock::Downloads => draw_downloads(f, app, content_rect), - ActiveRadarrBlock::Collections => draw_collections(f, app, content_rect), - ActiveRadarrBlock::MovieDetails - | ActiveRadarrBlock::MovieHistory - | ActiveRadarrBlock::FileInfo - | ActiveRadarrBlock::Cast - | ActiveRadarrBlock::Crew => { - draw_large_popup_over(f, app, content_rect, draw_library, draw_movie_info) - } - ActiveRadarrBlock::CollectionDetails | ActiveRadarrBlock::ViewMovieOverview => { - draw_large_popup_over( - f, - app, - content_rect, - draw_collections, - draw_collection_details_popup, - ) - } - _ => (), - } - } -} - -pub(super) fn draw_radarr_context_row(f: &mut Frame<'_, B>, app: &App, area: Rect) { - let chunks = horizontal_chunks(vec![Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], area); - - draw_stats_context(f, app, chunks[0]); - draw_downloads_context(f, app, chunks[1]); -} - -fn draw_library(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { - let quality_profile_map = &app.data.radarr_data.quality_profile_map; - let downloads_vec = &app.data.radarr_data.downloads.items; - - draw_table( - f, - area, - layout_block_top_border(), - TableProps { - content: &mut app.data.radarr_data.movies, - table_headers: vec![ - "Title", - "Year", - "Runtime", - "Rating", - "Language", - "Size", - "Quality Profile", - ], - constraints: vec![ - Constraint::Percentage(25), - Constraint::Percentage(12), - Constraint::Percentage(12), - Constraint::Percentage(12), - Constraint::Percentage(12), - Constraint::Percentage(12), - Constraint::Percentage(12), - ], - }, - |movie| { - let (hours, minutes) = convert_runtime(movie.runtime.as_u64().unwrap()); - let file_size: f64 = convert_to_gb(movie.size_on_disk.as_u64().unwrap()); - let certification = movie.certification.clone().unwrap_or_else(|| "".to_owned()); - - Row::new(vec![ - Cell::from(movie.title.to_owned()), - Cell::from(movie.year.to_string()), - Cell::from(format!("{}h {}m", hours, minutes)), - Cell::from(certification), - Cell::from(movie.original_language.name.to_owned()), - Cell::from(format!("{:.2} GB", file_size)), - Cell::from( - quality_profile_map - .get(&movie.quality_profile_id.as_u64().unwrap()) - .unwrap() - .to_owned(), - ), - ]) - .style(determine_row_style(downloads_vec, movie)) - }, - app.is_loading, - ); -} - -fn draw_downloads_context(f: &mut Frame<'_, B>, app: &App, area: Rect) { - let block = title_block("Downloads"); - let downloads_vec = &app.data.radarr_data.downloads.items; - - if !downloads_vec.is_empty() { - f.render_widget(block, area); - - let constraints = iter::repeat(Constraint::Min(2)) - .take(downloads_vec.len()) - .collect::>(); - - let chunks = vertical_chunks_with_margin(constraints, area, 1); - - for i in 0..downloads_vec.len() { - let DownloadRecord { - title, - sizeleft, - size, - .. - } = &downloads_vec[i]; - let percent = 1f64 - (sizeleft.as_f64().unwrap() / size.as_f64().unwrap()); - let download_gague = line_gauge_with_title(title, percent); - - f.render_widget(download_gague, chunks[i]); - } - } else { - loading(f, block, area, app.is_loading); - } -} - -fn draw_downloads(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { - let current_selection = if app.data.radarr_data.downloads.items.is_empty() { - DownloadRecord::default() - } else { - app.data.radarr_data.downloads.current_selection_clone() - }; - let width = (area.width as f32 * 0.30) as usize; - - draw_table( - f, - area, - layout_block_top_border(), - TableProps { - content: &mut app.data.radarr_data.downloads, - table_headers: vec![ - "Title", - "Percent Complete", - "Size", - "Output Path", - "Indexer", - "Download Client", - ], - constraints: vec![ - Constraint::Percentage(30), - Constraint::Percentage(11), - Constraint::Percentage(11), - Constraint::Percentage(18), - Constraint::Percentage(17), - Constraint::Percentage(13), - ], - }, - |download_record| { - let DownloadRecord { - title, - size, - sizeleft, - download_client, - indexer, - output_path, - .. - } = download_record; - - if current_selection == *download_record && output_path.text.len() > width { - output_path.scroll_text() - } else { - output_path.reset_offset(); - } - - let percent = 1f64 - (sizeleft.as_f64().unwrap() / size.as_f64().unwrap()); - let file_size: f64 = convert_to_gb(size.as_u64().unwrap()); - - Row::new(vec![ - Cell::from(title.to_owned()), - Cell::from(format!("{:.0}%", percent * 100.0)), - Cell::from(format!("{:.2} GB", file_size)), - Cell::from(output_path.to_string()), - Cell::from(indexer.to_owned()), - Cell::from(download_client.to_owned()), - ]) - .style(style_primary()) - }, - app.is_loading, - ); -} - -fn draw_collections(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { - let quality_profile_map = &app.data.radarr_data.quality_profile_map; - draw_table( - f, - area, - layout_block_top_border(), - TableProps { - content: &mut app.data.radarr_data.collections, - table_headers: vec![ - "Collection", - "Search on Add?", - "Number of Movies", - "Root Folder Path", - "Quality Profile", - ], - constraints: iter::repeat(Constraint::Ratio(1, 5)).take(5).collect(), - }, - |collection| { - let number_of_movies = collection.movies.clone().unwrap_or_default().len(); - - Row::new(vec![ - Cell::from(collection.title.to_owned()), - Cell::from(collection.search_on_add.to_string()), - Cell::from(number_of_movies.to_string()), - Cell::from(collection.root_folder_path.clone().unwrap_or_default()), - Cell::from( - quality_profile_map - .get(&collection.quality_profile_id.as_u64().unwrap()) - .unwrap() - .to_owned(), - ), - ]) - .style(style_primary()) - }, - app.is_loading, - ); -} - -fn draw_collection_details(f: &mut Frame<'_, B>, app: &mut App, content_area: Rect) { - let chunks = vertical_chunks_with_margin( - vec![ - Constraint::Percentage(20), - Constraint::Percentage(75), - Constraint::Percentage(5), - ], - content_area, - 1, - ); - let collection_selection = app.data.radarr_data.collections.current_selection(); - let quality_profile = app - .data - .radarr_data - .quality_profile_map - .get(&collection_selection.quality_profile_id.as_u64().unwrap()) - .unwrap() - .to_owned(); - let mut help_text = Text::from("<↑↓> scroll table | show overview | close"); - help_text.patch_style(style_help()); - - let collection_description = Text::from(vec![ - spans_info_primary( - "Overview: ".to_owned(), - collection_selection.overview.clone().unwrap_or_default(), - ), - spans_info_primary( - "Root Folder Path: ".to_owned(), - collection_selection - .root_folder_path - .clone() - .unwrap_or_default(), - ), - spans_info_primary( - "Search on Add: ".to_owned(), - collection_selection.search_on_add.to_string(), - ), - spans_info_primary("Quality Profile: ".to_owned(), quality_profile), - ]); - - let description_paragraph = Paragraph::new(collection_description) - .block(borderless_block()) - .wrap(Wrap { trim: false }); - let help_paragraph = Paragraph::new(help_text) - .block(borderless_block()) - .alignment(Alignment::Center); - - f.render_widget(title_block(&collection_selection.title), content_area); - - f.render_widget(description_paragraph, chunks[0]); - f.render_widget(help_paragraph, chunks[2]); - - draw_table( - f, - chunks[1], - layout_block_top_border_with_title(title_style("Movies")), - TableProps { - content: &mut app.data.radarr_data.collection_movies, - table_headers: vec![ - "Title", - "Year", - "Runtime", - "IMDB Rating", - "Rotten Tomatoes Rating", - "Genres", - ], - constraints: vec![ - Constraint::Percentage(20), - Constraint::Percentage(8), - Constraint::Percentage(10), - Constraint::Percentage(10), - Constraint::Percentage(18), - Constraint::Percentage(30), - ], - }, - |movie| { - let (hours, minutes) = convert_runtime(movie.runtime.as_u64().unwrap()); - let imdb_rating = movie - .ratings - .imdb - .clone() - .unwrap_or_default() - .value - .as_f64() - .unwrap(); - let rotten_tomatoes_rating = movie - .ratings - .rotten_tomatoes - .clone() - .unwrap_or_default() - .value - .as_u64() - .unwrap(); - let imdb_rating = if imdb_rating == 0.0 { - String::default() - } else { - format!("{:.1}", imdb_rating) - }; - let rotten_tomatoes_rating = if rotten_tomatoes_rating == 0 { - String::default() - } else { - format!("{}%", rotten_tomatoes_rating) - }; - - Row::new(vec![ - Cell::from(movie.title.to_owned()), - Cell::from(movie.year.as_u64().unwrap().to_string()), - Cell::from(format!("{}h {}m", hours, minutes)), - Cell::from(imdb_rating), - Cell::from(rotten_tomatoes_rating), - Cell::from(movie.genres.join(", ")), - ]) - .style(style_primary()) - }, - app.is_loading, - ); -} - -fn draw_collection_details_popup( - f: &mut Frame<'_, B>, - app: &mut App, - content_area: Rect, -) { - if let Route::Radarr(active_radarr_block) = app.get_current_route() { - match active_radarr_block { - ActiveRadarrBlock::ViewMovieOverview => { - draw_small_popup_over( - f, - app, - content_area, - draw_collection_details, - draw_movie_overview, - ); - } - ActiveRadarrBlock::CollectionDetails => draw_collection_details(f, app, content_area), - _ => (), - } - } -} - -fn draw_movie_info(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { - let (content_area, block) = - draw_tabs(f, area, "Movie Info", &app.data.radarr_data.movie_info_tabs); - - if let Route::Radarr(active_radarr_block) = - app.data.radarr_data.movie_info_tabs.get_active_route() - { - match active_radarr_block { - ActiveRadarrBlock::FileInfo => draw_file_info(f, app, content_area, block), - ActiveRadarrBlock::MovieDetails => draw_movie_details(f, app, content_area, block), - ActiveRadarrBlock::MovieHistory => draw_movie_history(f, app, content_area, block), - ActiveRadarrBlock::Cast => draw_movie_cast(f, app, content_area, block), - ActiveRadarrBlock::Crew => draw_movie_crew(f, app, content_area, block), - _ => (), - } - } -} - -fn draw_movie_overview(f: &mut Frame<'_, B>, app: &mut App, content_area: Rect) { - let title_block = title_block("Overview"); - f.render_widget(title_block, content_area); - - let chunks = vertical_chunks_with_margin( - vec![Constraint::Percentage(95), Constraint::Percentage(5)], - content_area, - 1, - ); - let mut overview = Text::from( - app - .data - .radarr_data - .collection_movies - .current_selection_clone() - .overview, - ); - overview.patch_style(style_default()); - let mut help_text = Text::from(" close"); - help_text.patch_style(style_help()); - - let paragraph = Paragraph::new(overview) - .block(borderless_block()) - .wrap(Wrap { trim: false }); - let help_paragraph = Paragraph::new(help_text) - .block(borderless_block()) - .alignment(Alignment::Center); - - f.render_widget(paragraph, chunks[0]); - f.render_widget(help_paragraph, chunks[1]); -} - -fn draw_movie_details( - f: &mut Frame<'_, B>, - app: &App, - content_area: Rect, - block: Block, -) { - let movie_details = app.data.radarr_data.movie_details.get_text(); - - if !movie_details.is_empty() { - let download_status = app - .data - .radarr_data - .movie_details - .items - .iter() - .find(|&line| line.starts_with("Status: ")) - .unwrap() - .split(": ") - .collect::>()[1]; - let mut text = Text::from( - app - .data - .radarr_data - .movie_details - .items - .iter() - .map(|line| { - let split = line.split(':').collect::>(); - let title = format!("{}:", split[0]); - - spans_info_default(title, split[1].to_owned()) - }) - .collect::>(), - ); - text.patch_style(determine_style_from_download_status(download_status)); - - let paragraph = Paragraph::new(text) - .block(block) - .wrap(Wrap { trim: false }) - .scroll((app.data.radarr_data.movie_details.offset, 0)); - - f.render_widget(paragraph, content_area); - } else { - loading(f, block, content_area, app.is_loading); - } -} - -fn draw_file_info(f: &mut Frame<'_, B>, app: &App, content_area: Rect, block: Block) { - let file_info = app.data.radarr_data.file_details.to_owned(); - - if !file_info.is_empty() { - let audio_details = app.data.radarr_data.audio_details.to_owned(); - let video_details = app.data.radarr_data.video_details.to_owned(); - let chunks = vertical_chunks( - vec![ - Constraint::Length(1), - Constraint::Length(5), - Constraint::Length(1), - Constraint::Length(6), - Constraint::Length(1), - Constraint::Length(7), - ], - content_area, - ); - let mut file_details_title = Text::from("File Details"); - let mut audio_details_title = Text::from("Audio Details"); - let mut video_details_title = Text::from("Video Details"); - file_details_title.patch_style(style_bold()); - audio_details_title.patch_style(style_bold()); - video_details_title.patch_style(style_bold()); - - let file_details_title_paragraph = Paragraph::new(file_details_title).block(borderless_block()); - let audio_details_title_paragraph = - Paragraph::new(audio_details_title).block(borderless_block()); - let video_details_title_paragraph = - Paragraph::new(video_details_title).block(borderless_block()); - - let file_details = Text::from(file_info); - let audio_details = Text::from(audio_details); - let video_details = Text::from(video_details); - - let file_details_paragraph = Paragraph::new(file_details) - .block(layout_block_bottom_border()) - .wrap(Wrap { trim: false }); - let audio_details_paragraph = Paragraph::new(audio_details) - .block(layout_block_bottom_border()) - .wrap(Wrap { trim: false }); - let video_details_paragraph = Paragraph::new(video_details) - .block(borderless_block()) - .wrap(Wrap { trim: false }); - - f.render_widget(file_details_title_paragraph, chunks[0]); - f.render_widget(file_details_paragraph, chunks[1]); - f.render_widget(audio_details_title_paragraph, chunks[2]); - f.render_widget(audio_details_paragraph, chunks[3]); - f.render_widget(video_details_title_paragraph, chunks[4]); - f.render_widget(video_details_paragraph, chunks[5]); - } else { - loading(f, block, content_area, app.is_loading); - } -} - -fn draw_movie_history( - f: &mut Frame<'_, B>, - app: &mut App, - content_area: Rect, - block: Block, -) { - let current_selection = if app.data.radarr_data.movie_history.items.is_empty() { - MovieHistoryItem::default() - } else { - app.data.radarr_data.movie_history.current_selection_clone() - }; - - draw_table( - f, - content_area, - block, - TableProps { - content: &mut app.data.radarr_data.movie_history, - table_headers: vec!["Source Title", "Event Type", "Languages", "Quality", "Date"], - constraints: vec![ - Constraint::Percentage(34), - Constraint::Percentage(17), - Constraint::Percentage(14), - Constraint::Percentage(14), - Constraint::Percentage(21), - ], - }, - |movie_history_item| { - let MovieHistoryItem { - source_title, - quality, - languages, - date, - event_type, - } = movie_history_item; - - if current_selection == *movie_history_item - && movie_history_item.source_title.text.len() > (content_area.width as f64 * 0.34) as usize - { - source_title.scroll_text(); - } else { - source_title.reset_offset(); - } - - Row::new(vec![ - Cell::from(source_title.to_string()), - Cell::from(event_type.to_owned()), - Cell::from( - languages - .iter() - .map(|language| language.name.to_owned()) - .collect::>() - .join(","), - ), - Cell::from(quality.quality.name.to_owned()), - Cell::from(date.to_string()), - ]) - .style(style_success()) - }, - app.is_loading, - ); -} - -fn draw_movie_cast( - f: &mut Frame<'_, B>, - app: &mut App, - content_area: Rect, - block: Block, -) { - draw_table( - f, - content_area, - block, - TableProps { - content: &mut app.data.radarr_data.movie_cast, - constraints: iter::repeat(Constraint::Ratio(1, 2)).take(2).collect(), - table_headers: vec!["Cast Member", "Character"], - }, - |cast_member| { - let Credit { - person_name, - character, - .. - } = cast_member; - - Row::new(vec![ - Cell::from(person_name.to_owned()), - Cell::from(character.clone().unwrap_or_default()), - ]) - .style(style_success()) - }, - app.is_loading, - ) -} - -fn draw_movie_crew( - f: &mut Frame<'_, B>, - app: &mut App, - content_area: Rect, - block: Block, -) { - draw_table( - f, - content_area, - block, - TableProps { - content: &mut app.data.radarr_data.movie_crew, - constraints: iter::repeat(Constraint::Ratio(1, 3)).take(3).collect(), - table_headers: vec!["Crew Member", "Job", "Department"], - }, - |crew_member| { - let Credit { - person_name, - job, - department, - .. - } = crew_member; - - Row::new(vec![ - Cell::from(person_name.to_owned()), - Cell::from(job.clone().unwrap_or_default()), - Cell::from(department.clone().unwrap_or_default()), - ]) - .style(style_success()) - }, - app.is_loading, - ); -} - -fn draw_stats_context(f: &mut Frame<'_, B>, app: &App, area: Rect) { - let block = title_block("Stats"); - - if !app.data.radarr_data.version.is_empty() { - f.render_widget(block, area); - let RadarrData { - disk_space_vec, - start_time, - .. - } = &app.data.radarr_data; - - let mut constraints = vec![ - Constraint::Percentage(60), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]; - - constraints.append( - &mut iter::repeat(Constraint::Min(2)) - .take(disk_space_vec.len()) - .collect(), - ); - - let chunks = vertical_chunks_with_margin(constraints, area, 1); - - let version_paragraph = Paragraph::new(Text::from(format!( - "Radarr Version: {}", - app.data.radarr_data.version - ))) - .block(borderless_block()); - - let uptime = Utc::now().sub(start_time.to_owned()); - let days = uptime.num_days(); - let day_difference = uptime.sub(Duration::days(days)); - let hours = day_difference.num_hours(); - let hour_difference = day_difference.sub(Duration::hours(hours)); - let minutes = hour_difference.num_minutes(); - let seconds = hour_difference - .sub(Duration::minutes(minutes)) - .num_seconds(); - - let uptime_paragraph = Paragraph::new(Text::from(format!( - "Uptime: {}d {:0width$}:{:0width$}:{:0width$}", - days, - hours, - minutes, - seconds, - width = 2 - ))) - .block(borderless_block()); - - let mut logo_text = Text::from(RADARR_LOGO); - logo_text.patch_style(Style::default().fg(Color::LightYellow)); - let logo = Paragraph::new(logo_text) - .block(borderless_block()) - .alignment(Alignment::Center); - let storage = - Paragraph::new(Text::from("Storage:")).block(borderless_block().style(style_bold())); - - f.render_widget(logo, chunks[0]); - f.render_widget(version_paragraph, chunks[1]); - f.render_widget(uptime_paragraph, chunks[2]); - f.render_widget(storage, chunks[3]); - - for i in 0..disk_space_vec.len() { - let DiskSpace { - free_space, - total_space, - } = &disk_space_vec[i]; - let title = format!("Disk {}", i + 1); - let ratio = if total_space.as_u64().unwrap() == 0 { - 0f64 - } else { - 1f64 - (free_space.as_u64().unwrap() as f64 / total_space.as_u64().unwrap() as f64) - }; - - let space_gauge = line_gauge_with_label(title.as_str(), ratio); - - f.render_widget(space_gauge, chunks[i + 4]); - } - } else { - loading(f, block, area, app.is_loading); - } -} - -fn determine_row_style(downloads_vec: &[DownloadRecord], movie: &Movie) -> Style { - if !movie.has_file { - if let Some(download) = downloads_vec - .iter() - .find(|&download| download.movie_id == movie.id) - { - if download.status == "downloading" { - return style_warning(); - } - } - - return style_failure(); - } - - style_success() -} - -fn determine_style_from_download_status(download_status: &str) -> Style { - match download_status { - "Downloaded" => style_success(), - "Downloading" => style_warning(), - "Missing" => style_failure(), - _ => style_success(), - } -} diff --git a/src/ui/radarr_ui/collection_details_ui.rs b/src/ui/radarr_ui/collection_details_ui.rs new file mode 100644 index 0000000..903af43 --- /dev/null +++ b/src/ui/radarr_ui/collection_details_ui.rs @@ -0,0 +1,187 @@ +use tui::backend::Backend; +use tui::layout::{Alignment, Constraint, Rect}; +use tui::text::Text; +use tui::widgets::{Cell, Paragraph, Row, Wrap}; +use tui::Frame; + +use crate::app::radarr::ActiveRadarrBlock; +use crate::app::App; +use crate::models::Route; +use crate::ui::utils::{ + borderless_block, layout_block_top_border_with_title, spans_info_primary, style_default, + style_help, style_primary, title_block, title_style, vertical_chunks_with_margin, +}; +use crate::ui::{draw_small_popup_over, draw_table, TableProps}; +use crate::utils::convert_runtime; + +pub(super) fn draw_collection_details_popup( + f: &mut Frame<'_, B>, + app: &mut App, + content_area: Rect, +) { + if let Route::Radarr(active_radarr_block) = app.get_current_route() { + match active_radarr_block { + ActiveRadarrBlock::ViewMovieOverview => { + draw_small_popup_over( + f, + app, + content_area, + draw_collection_details, + draw_movie_overview, + ); + } + ActiveRadarrBlock::CollectionDetails => draw_collection_details(f, app, content_area), + _ => (), + } + } +} + +fn draw_collection_details(f: &mut Frame<'_, B>, app: &mut App, content_area: Rect) { + let chunks = vertical_chunks_with_margin( + vec![ + Constraint::Percentage(20), + Constraint::Percentage(75), + Constraint::Percentage(5), + ], + content_area, + 1, + ); + let collection_selection = app.data.radarr_data.collections.current_selection(); + let quality_profile = app + .data + .radarr_data + .quality_profile_map + .get(&collection_selection.quality_profile_id.as_u64().unwrap()) + .unwrap() + .to_owned(); + let mut help_text = Text::from("<↑↓> scroll table | show overview | close"); + help_text.patch_style(style_help()); + + let collection_description = Text::from(vec![ + spans_info_primary( + "Overview: ".to_owned(), + collection_selection.overview.clone().unwrap_or_default(), + ), + spans_info_primary( + "Root Folder Path: ".to_owned(), + collection_selection + .root_folder_path + .clone() + .unwrap_or_default(), + ), + spans_info_primary( + "Search on Add: ".to_owned(), + collection_selection.search_on_add.to_string(), + ), + spans_info_primary("Quality Profile: ".to_owned(), quality_profile), + ]); + + let description_paragraph = Paragraph::new(collection_description) + .block(borderless_block()) + .wrap(Wrap { trim: false }); + let help_paragraph = Paragraph::new(help_text) + .block(borderless_block()) + .alignment(Alignment::Center); + + f.render_widget(title_block(&collection_selection.title), content_area); + + f.render_widget(description_paragraph, chunks[0]); + f.render_widget(help_paragraph, chunks[2]); + + draw_table( + f, + chunks[1], + layout_block_top_border_with_title(title_style("Movies")), + TableProps { + content: &mut app.data.radarr_data.collection_movies, + table_headers: vec![ + "Title", + "Year", + "Runtime", + "IMDB Rating", + "Rotten Tomatoes Rating", + "Genres", + ], + constraints: vec![ + Constraint::Percentage(20), + Constraint::Percentage(8), + Constraint::Percentage(10), + Constraint::Percentage(10), + Constraint::Percentage(18), + Constraint::Percentage(30), + ], + }, + |movie| { + let (hours, minutes) = convert_runtime(movie.runtime.as_u64().unwrap()); + let imdb_rating = movie + .ratings + .imdb + .clone() + .unwrap_or_default() + .value + .as_f64() + .unwrap(); + let rotten_tomatoes_rating = movie + .ratings + .rotten_tomatoes + .clone() + .unwrap_or_default() + .value + .as_u64() + .unwrap(); + let imdb_rating = if imdb_rating == 0.0 { + String::default() + } else { + format!("{:.1}", imdb_rating) + }; + let rotten_tomatoes_rating = if rotten_tomatoes_rating == 0 { + String::default() + } else { + format!("{}%", rotten_tomatoes_rating) + }; + + Row::new(vec![ + Cell::from(movie.title.to_owned()), + Cell::from(movie.year.as_u64().unwrap().to_string()), + Cell::from(format!("{}h {}m", hours, minutes)), + Cell::from(imdb_rating), + Cell::from(rotten_tomatoes_rating), + Cell::from(movie.genres.join(", ")), + ]) + .style(style_primary()) + }, + app.is_loading, + ); +} + +fn draw_movie_overview(f: &mut Frame<'_, B>, app: &mut App, content_area: Rect) { + let title_block = title_block("Overview"); + f.render_widget(title_block, content_area); + + let chunks = vertical_chunks_with_margin( + vec![Constraint::Percentage(95), Constraint::Percentage(5)], + content_area, + 1, + ); + let mut overview = Text::from( + app + .data + .radarr_data + .collection_movies + .current_selection_clone() + .overview, + ); + overview.patch_style(style_default()); + let mut help_text = Text::from(" close"); + help_text.patch_style(style_help()); + + let paragraph = Paragraph::new(overview) + .block(borderless_block()) + .wrap(Wrap { trim: false }); + let help_paragraph = Paragraph::new(help_text) + .block(borderless_block()) + .alignment(Alignment::Center); + + f.render_widget(paragraph, chunks[0]); + f.render_widget(help_paragraph, chunks[1]); +} diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs new file mode 100644 index 0000000..3ae162c --- /dev/null +++ b/src/ui/radarr_ui/mod.rs @@ -0,0 +1,384 @@ +use std::iter; +use std::ops::Sub; + +use chrono::{Duration, Utc}; +use tui::backend::Backend; +use tui::layout::{Alignment, Constraint, Rect}; +use tui::style::{Color, Style}; +use tui::text::Text; +use tui::widgets::{Cell, Paragraph, Row}; +use tui::Frame; + +use crate::app::radarr::{ActiveRadarrBlock, RadarrData}; +use crate::app::App; +use crate::logos::RADARR_LOGO; +use crate::models::radarr_models::{DiskSpace, DownloadRecord, Movie}; +use crate::models::Route; +use crate::ui::radarr_ui::collection_details_ui::draw_collection_details_popup; +use crate::ui::radarr_ui::movie_details_ui::draw_movie_info; +use crate::ui::utils::{ + borderless_block, horizontal_chunks, layout_block_top_border, line_gauge_with_label, + line_gauge_with_title, style_bold, style_default, style_failure, style_primary, style_success, + style_warning, title_block, vertical_chunks_with_margin, +}; +use crate::ui::{ + draw_large_popup_over, draw_popup_over, draw_table, draw_tabs, loading, TableProps, +}; +use crate::utils::{convert_runtime, convert_to_gb}; + +mod collection_details_ui; +mod movie_details_ui; + +pub(super) fn draw_radarr_ui(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { + let (content_rect, _) = draw_tabs(f, area, " Movies ", &app.data.radarr_data.main_tabs); + + if let Route::Radarr(active_radarr_block) = app.get_current_route().clone() { + match active_radarr_block { + ActiveRadarrBlock::Movies => draw_library(f, app, content_rect), + ActiveRadarrBlock::SearchMovie => { + draw_popup_over(f, app, content_rect, draw_library, draw_search_box, 30, 10) + } + ActiveRadarrBlock::Downloads => draw_downloads(f, app, content_rect), + ActiveRadarrBlock::Collections => draw_collections(f, app, content_rect), + ActiveRadarrBlock::MovieDetails + | ActiveRadarrBlock::MovieHistory + | ActiveRadarrBlock::FileInfo + | ActiveRadarrBlock::Cast + | ActiveRadarrBlock::Crew => { + draw_large_popup_over(f, app, content_rect, draw_library, draw_movie_info) + } + ActiveRadarrBlock::CollectionDetails | ActiveRadarrBlock::ViewMovieOverview => { + draw_large_popup_over( + f, + app, + content_rect, + draw_collections, + draw_collection_details_popup, + ) + } + _ => (), + } + } +} + +pub(super) fn draw_radarr_context_row(f: &mut Frame<'_, B>, app: &App, area: Rect) { + let chunks = horizontal_chunks(vec![Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], area); + + draw_stats_context(f, app, chunks[0]); + draw_downloads_context(f, app, chunks[1]); +} + +fn draw_library(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { + let quality_profile_map = &app.data.radarr_data.quality_profile_map; + let downloads_vec = &app.data.radarr_data.downloads.items; + + draw_table( + f, + area, + layout_block_top_border(), + TableProps { + content: &mut app.data.radarr_data.movies, + table_headers: vec![ + "Title", + "Year", + "Runtime", + "Rating", + "Language", + "Size", + "Quality Profile", + ], + constraints: vec![ + Constraint::Percentage(25), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(12), + ], + }, + |movie| { + let (hours, minutes) = convert_runtime(movie.runtime.as_u64().unwrap()); + let file_size: f64 = convert_to_gb(movie.size_on_disk.as_u64().unwrap()); + let certification = movie.certification.clone().unwrap_or_else(|| "".to_owned()); + + Row::new(vec![ + Cell::from(movie.title.to_owned()), + Cell::from(movie.year.to_string()), + Cell::from(format!("{}h {}m", hours, minutes)), + Cell::from(certification), + Cell::from(movie.original_language.name.to_owned()), + Cell::from(format!("{:.2} GB", file_size)), + Cell::from( + quality_profile_map + .get(&movie.quality_profile_id.as_u64().unwrap()) + .unwrap() + .to_owned(), + ), + ]) + .style(determine_row_style(downloads_vec, movie)) + }, + app.is_loading, + ); +} + +fn draw_search_box(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { + let chunks = vertical_chunks_with_margin(vec![Constraint::Length(3)], area, 1); + if !app.data.radarr_data.is_searching { + let input = Paragraph::new("Movie not found!") + .style(style_failure()) + .block(title_block("Search")); + f.set_cursor( + chunks[0].x + app.data.radarr_data.search.len() as u16 + 1, + chunks[0].y + 1, + ); + + f.render_widget(input, chunks[0]); + } else { + let input = Paragraph::new(app.data.radarr_data.search.as_ref()) + .style(style_default()) + .block(title_block("Search")); + f.set_cursor( + chunks[0].x + app.data.radarr_data.search.len() as u16 + 1, + chunks[0].y + 1, + ); + + f.render_widget(input, chunks[0]); + } +} + +fn draw_downloads_context(f: &mut Frame<'_, B>, app: &App, area: Rect) { + let block = title_block("Downloads"); + let downloads_vec = &app.data.radarr_data.downloads.items; + + if !downloads_vec.is_empty() { + f.render_widget(block, area); + + let constraints = iter::repeat(Constraint::Min(2)) + .take(downloads_vec.len()) + .collect::>(); + + let chunks = vertical_chunks_with_margin(constraints, area, 1); + + for i in 0..downloads_vec.len() { + let DownloadRecord { + title, + sizeleft, + size, + .. + } = &downloads_vec[i]; + let percent = 1f64 - (sizeleft.as_f64().unwrap() / size.as_f64().unwrap()); + let download_gague = line_gauge_with_title(title, percent); + + f.render_widget(download_gague, chunks[i]); + } + } else { + loading(f, block, area, app.is_loading); + } +} + +fn draw_downloads(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { + let current_selection = if app.data.radarr_data.downloads.items.is_empty() { + DownloadRecord::default() + } else { + app.data.radarr_data.downloads.current_selection_clone() + }; + let width = (area.width as f32 * 0.30) as usize; + + draw_table( + f, + area, + layout_block_top_border(), + TableProps { + content: &mut app.data.radarr_data.downloads, + table_headers: vec![ + "Title", + "Percent Complete", + "Size", + "Output Path", + "Indexer", + "Download Client", + ], + constraints: vec![ + Constraint::Percentage(30), + Constraint::Percentage(11), + Constraint::Percentage(11), + Constraint::Percentage(18), + Constraint::Percentage(17), + Constraint::Percentage(13), + ], + }, + |download_record| { + let DownloadRecord { + title, + size, + sizeleft, + download_client, + indexer, + output_path, + .. + } = download_record; + + if current_selection == *download_record && output_path.text.len() > width { + output_path.scroll_text() + } else { + output_path.reset_offset(); + } + + let percent = 1f64 - (sizeleft.as_f64().unwrap() / size.as_f64().unwrap()); + let file_size: f64 = convert_to_gb(size.as_u64().unwrap()); + + Row::new(vec![ + Cell::from(title.to_owned()), + Cell::from(format!("{:.0}%", percent * 100.0)), + Cell::from(format!("{:.2} GB", file_size)), + Cell::from(output_path.to_string()), + Cell::from(indexer.to_owned()), + Cell::from(download_client.to_owned()), + ]) + .style(style_primary()) + }, + app.is_loading, + ); +} + +fn draw_collections(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { + let quality_profile_map = &app.data.radarr_data.quality_profile_map; + draw_table( + f, + area, + layout_block_top_border(), + TableProps { + content: &mut app.data.radarr_data.collections, + table_headers: vec![ + "Collection", + "Search on Add?", + "Number of Movies", + "Root Folder Path", + "Quality Profile", + ], + constraints: iter::repeat(Constraint::Ratio(1, 5)).take(5).collect(), + }, + |collection| { + let number_of_movies = collection.movies.clone().unwrap_or_default().len(); + + Row::new(vec![ + Cell::from(collection.title.to_owned()), + Cell::from(collection.search_on_add.to_string()), + Cell::from(number_of_movies.to_string()), + Cell::from(collection.root_folder_path.clone().unwrap_or_default()), + Cell::from( + quality_profile_map + .get(&collection.quality_profile_id.as_u64().unwrap()) + .unwrap() + .to_owned(), + ), + ]) + .style(style_primary()) + }, + app.is_loading, + ); +} + +fn draw_stats_context(f: &mut Frame<'_, B>, app: &App, area: Rect) { + let block = title_block("Stats"); + + if !app.data.radarr_data.version.is_empty() { + f.render_widget(block, area); + let RadarrData { + disk_space_vec, + start_time, + .. + } = &app.data.radarr_data; + + let mut constraints = vec![ + Constraint::Percentage(60), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]; + + constraints.append( + &mut iter::repeat(Constraint::Min(2)) + .take(disk_space_vec.len()) + .collect(), + ); + + let chunks = vertical_chunks_with_margin(constraints, area, 1); + + let version_paragraph = Paragraph::new(Text::from(format!( + "Radarr Version: {}", + app.data.radarr_data.version + ))) + .block(borderless_block()); + + let uptime = Utc::now().sub(start_time.to_owned()); + let days = uptime.num_days(); + let day_difference = uptime.sub(Duration::days(days)); + let hours = day_difference.num_hours(); + let hour_difference = day_difference.sub(Duration::hours(hours)); + let minutes = hour_difference.num_minutes(); + let seconds = hour_difference + .sub(Duration::minutes(minutes)) + .num_seconds(); + + let uptime_paragraph = Paragraph::new(Text::from(format!( + "Uptime: {}d {:0width$}:{:0width$}:{:0width$}", + days, + hours, + minutes, + seconds, + width = 2 + ))) + .block(borderless_block()); + + let mut logo_text = Text::from(RADARR_LOGO); + logo_text.patch_style(Style::default().fg(Color::LightYellow)); + let logo = Paragraph::new(logo_text) + .block(borderless_block()) + .alignment(Alignment::Center); + let storage = + Paragraph::new(Text::from("Storage:")).block(borderless_block().style(style_bold())); + + f.render_widget(logo, chunks[0]); + f.render_widget(version_paragraph, chunks[1]); + f.render_widget(uptime_paragraph, chunks[2]); + f.render_widget(storage, chunks[3]); + + for i in 0..disk_space_vec.len() { + let DiskSpace { + free_space, + total_space, + } = &disk_space_vec[i]; + let title = format!("Disk {}", i + 1); + let ratio = if total_space.as_u64().unwrap() == 0 { + 0f64 + } else { + 1f64 - (free_space.as_u64().unwrap() as f64 / total_space.as_u64().unwrap() as f64) + }; + + let space_gauge = line_gauge_with_label(title.as_str(), ratio); + + f.render_widget(space_gauge, chunks[i + 4]); + } + } else { + loading(f, block, area, app.is_loading); + } +} + +fn determine_row_style(downloads_vec: &[DownloadRecord], movie: &Movie) -> Style { + if !movie.has_file { + if let Some(download) = downloads_vec + .iter() + .find(|&download| download.movie_id == movie.id) + { + if download.status == "downloading" { + return style_warning(); + } + } + + return style_failure(); + } + + style_success() +} diff --git a/src/ui/radarr_ui/movie_details_ui.rs b/src/ui/radarr_ui/movie_details_ui.rs new file mode 100644 index 0000000..a141d61 --- /dev/null +++ b/src/ui/radarr_ui/movie_details_ui.rs @@ -0,0 +1,276 @@ +use std::iter; + +use tui::backend::Backend; +use tui::layout::{Constraint, Rect}; +use tui::style::Style; +use tui::text::{Spans, Text}; +use tui::widgets::{Block, Cell, Paragraph, Row, Wrap}; +use tui::Frame; + +use crate::app::radarr::ActiveRadarrBlock; +use crate::app::App; +use crate::models::radarr_models::{Credit, MovieHistoryItem}; +use crate::models::Route; +use crate::ui::utils::{ + borderless_block, layout_block_bottom_border, spans_info_default, style_bold, style_failure, + style_success, style_warning, vertical_chunks, +}; +use crate::ui::{draw_table, draw_tabs, loading, TableProps}; + +pub(super) fn draw_movie_info(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { + let (content_area, block) = + draw_tabs(f, area, "Movie Info", &app.data.radarr_data.movie_info_tabs); + + if let Route::Radarr(active_radarr_block) = + app.data.radarr_data.movie_info_tabs.get_active_route() + { + match active_radarr_block { + ActiveRadarrBlock::FileInfo => draw_file_info(f, app, content_area, block), + ActiveRadarrBlock::MovieDetails => draw_movie_details(f, app, content_area, block), + ActiveRadarrBlock::MovieHistory => draw_movie_history(f, app, content_area, block), + ActiveRadarrBlock::Cast => draw_movie_cast(f, app, content_area, block), + ActiveRadarrBlock::Crew => draw_movie_crew(f, app, content_area, block), + _ => (), + } + } +} + +fn draw_file_info(f: &mut Frame<'_, B>, app: &App, content_area: Rect, block: Block) { + let file_info = app.data.radarr_data.file_details.to_owned(); + + if !file_info.is_empty() { + let audio_details = app.data.radarr_data.audio_details.to_owned(); + let video_details = app.data.radarr_data.video_details.to_owned(); + let chunks = vertical_chunks( + vec![ + Constraint::Length(1), + Constraint::Length(5), + Constraint::Length(1), + Constraint::Length(6), + Constraint::Length(1), + Constraint::Length(7), + ], + content_area, + ); + let mut file_details_title = Text::from("File Details"); + let mut audio_details_title = Text::from("Audio Details"); + let mut video_details_title = Text::from("Video Details"); + file_details_title.patch_style(style_bold()); + audio_details_title.patch_style(style_bold()); + video_details_title.patch_style(style_bold()); + + let file_details_title_paragraph = Paragraph::new(file_details_title).block(borderless_block()); + let audio_details_title_paragraph = + Paragraph::new(audio_details_title).block(borderless_block()); + let video_details_title_paragraph = + Paragraph::new(video_details_title).block(borderless_block()); + + let file_details = Text::from(file_info); + let audio_details = Text::from(audio_details); + let video_details = Text::from(video_details); + + let file_details_paragraph = Paragraph::new(file_details) + .block(layout_block_bottom_border()) + .wrap(Wrap { trim: false }); + let audio_details_paragraph = Paragraph::new(audio_details) + .block(layout_block_bottom_border()) + .wrap(Wrap { trim: false }); + let video_details_paragraph = Paragraph::new(video_details) + .block(borderless_block()) + .wrap(Wrap { trim: false }); + + f.render_widget(file_details_title_paragraph, chunks[0]); + f.render_widget(file_details_paragraph, chunks[1]); + f.render_widget(audio_details_title_paragraph, chunks[2]); + f.render_widget(audio_details_paragraph, chunks[3]); + f.render_widget(video_details_title_paragraph, chunks[4]); + f.render_widget(video_details_paragraph, chunks[5]); + } else { + loading(f, block, content_area, app.is_loading); + } +} + +fn draw_movie_details( + f: &mut Frame<'_, B>, + app: &App, + content_area: Rect, + block: Block, +) { + let movie_details = app.data.radarr_data.movie_details.get_text(); + + if !movie_details.is_empty() { + let download_status = app + .data + .radarr_data + .movie_details + .items + .iter() + .find(|&line| line.starts_with("Status: ")) + .unwrap() + .split(": ") + .collect::>()[1]; + let mut text = Text::from( + app + .data + .radarr_data + .movie_details + .items + .iter() + .map(|line| { + let split = line.split(':').collect::>(); + let title = format!("{}:", split[0]); + + spans_info_default(title, split[1].to_owned()) + }) + .collect::>(), + ); + text.patch_style(determine_style_from_download_status(download_status)); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((app.data.radarr_data.movie_details.offset, 0)); + + f.render_widget(paragraph, content_area); + } else { + loading(f, block, content_area, app.is_loading); + } +} + +fn draw_movie_history( + f: &mut Frame<'_, B>, + app: &mut App, + content_area: Rect, + block: Block, +) { + let current_selection = if app.data.radarr_data.movie_history.items.is_empty() { + MovieHistoryItem::default() + } else { + app.data.radarr_data.movie_history.current_selection_clone() + }; + + draw_table( + f, + content_area, + block, + TableProps { + content: &mut app.data.radarr_data.movie_history, + table_headers: vec!["Source Title", "Event Type", "Languages", "Quality", "Date"], + constraints: vec![ + Constraint::Percentage(34), + Constraint::Percentage(17), + Constraint::Percentage(14), + Constraint::Percentage(14), + Constraint::Percentage(21), + ], + }, + |movie_history_item| { + let MovieHistoryItem { + source_title, + quality, + languages, + date, + event_type, + } = movie_history_item; + + if current_selection == *movie_history_item + && movie_history_item.source_title.text.len() > (content_area.width as f64 * 0.34) as usize + { + source_title.scroll_text(); + } else { + source_title.reset_offset(); + } + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_owned()), + Cell::from( + languages + .iter() + .map(|language| language.name.to_owned()) + .collect::>() + .join(","), + ), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .style(style_success()) + }, + app.is_loading, + ); +} + +fn draw_movie_cast( + f: &mut Frame<'_, B>, + app: &mut App, + content_area: Rect, + block: Block, +) { + draw_table( + f, + content_area, + block, + TableProps { + content: &mut app.data.radarr_data.movie_cast, + constraints: iter::repeat(Constraint::Ratio(1, 2)).take(2).collect(), + table_headers: vec!["Cast Member", "Character"], + }, + |cast_member| { + let Credit { + person_name, + character, + .. + } = cast_member; + + Row::new(vec![ + Cell::from(person_name.to_owned()), + Cell::from(character.clone().unwrap_or_default()), + ]) + .style(style_success()) + }, + app.is_loading, + ) +} + +fn draw_movie_crew( + f: &mut Frame<'_, B>, + app: &mut App, + content_area: Rect, + block: Block, +) { + draw_table( + f, + content_area, + block, + TableProps { + content: &mut app.data.radarr_data.movie_crew, + constraints: iter::repeat(Constraint::Ratio(1, 3)).take(3).collect(), + table_headers: vec!["Crew Member", "Job", "Department"], + }, + |crew_member| { + let Credit { + person_name, + job, + department, + .. + } = crew_member; + + Row::new(vec![ + Cell::from(person_name.to_owned()), + Cell::from(job.clone().unwrap_or_default()), + Cell::from(department.clone().unwrap_or_default()), + ]) + .style(style_success()) + }, + app.is_loading, + ); +} + +fn determine_style_from_download_status(download_status: &str) -> Style { + match download_status { + "Downloaded" => style_success(), + "Downloading" => style_warning(), + "Missing" => style_failure(), + _ => style_success(), + } +}