From 82e51be0968b1a7515b213763200a574e74d0390 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 13:53:28 -0700 Subject: [PATCH] feat(ui): Add series support Sonarr --- src/app/context_clues.rs | 5 + src/app/context_clues_tests.rs | 22 +- src/app/radarr/radarr_context_clues.rs | 5 - src/app/radarr/radarr_context_clues_tests.rs | 18 +- src/app/sonarr/mod.rs | 5 + src/app/sonarr/sonarr_context_clues.rs | 5 + src/app/sonarr/sonarr_context_clues_tests.rs | 25 +- src/app/sonarr/sonarr_tests.rs | 17 + .../collections/edit_collection_ui.rs | 3 +- src/ui/radarr_ui/indexers/edit_indexer_ui.rs | 3 +- .../radarr_ui/indexers/indexer_settings_ui.rs | 3 +- src/ui/radarr_ui/library/add_movie_ui.rs | 6 +- src/ui/radarr_ui/library/edit_movie_ui.rs | 3 +- src/ui/sonarr_ui/library/add_series_ui.rs | 486 ++++++++++++++++++ .../sonarr_ui/library/add_series_ui_tests.rs | 19 + src/ui/sonarr_ui/library/library_ui_tests.rs | 5 +- src/ui/sonarr_ui/library/mod.rs | 7 +- src/ui/widgets/confirmation_prompt.rs | 3 +- src/ui/widgets/popup.rs | 2 + src/ui/widgets/popup_tests.rs | 1 + 20 files changed, 599 insertions(+), 44 deletions(-) create mode 100644 src/ui/sonarr_ui/library/add_series_ui.rs create mode 100644 src/ui/sonarr_ui/library/add_series_ui_tests.rs diff --git a/src/app/context_clues.rs b/src/app/context_clues.rs index d1c00ed..32a6463 100644 --- a/src/app/context_clues.rs +++ b/src/app/context_clues.rs @@ -40,6 +40,11 @@ pub static BLOCKLIST_CONTEXT_CLUES: [ContextClue; 5] = [ (DEFAULT_KEYBINDINGS.clear, "clear blocklist"), ]; +pub static CONFIRMATION_PROMPT_CONTEXT_CLUES: [ContextClue; 2] = [ + (DEFAULT_KEYBINDINGS.confirm, "submit"), + (DEFAULT_KEYBINDINGS.esc, "cancel"), +]; + pub static DOWNLOADS_CONTEXT_CLUES: [ContextClue; 3] = [ ( DEFAULT_KEYBINDINGS.refresh, diff --git a/src/app/context_clues_tests.rs b/src/app/context_clues_tests.rs index ac76c8b..0e9fdaa 100644 --- a/src/app/context_clues_tests.rs +++ b/src/app/context_clues_tests.rs @@ -3,9 +3,9 @@ mod test { use pretty_assertions::{assert_eq, assert_str_eq}; use crate::app::context_clues::{ - BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, - INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SERVARR_CONTEXT_CLUES, - SYSTEM_CONTEXT_CLUES, + BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, + DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, + SERVARR_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, }; use crate::app::{context_clues::build_context_clue_string, key_binding::DEFAULT_KEYBINDINGS}; @@ -106,6 +106,22 @@ mod test { assert_eq!(blocklist_context_clues_iter.next(), None); } + #[test] + fn test_confirmation_prompt_context_clues() { + let mut confirmation_prompt_context_clues_iter = CONFIRMATION_PROMPT_CONTEXT_CLUES.iter(); + + let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.confirm); + assert_str_eq!(*description, "submit"); + + let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, "cancel"); + assert_eq!(confirmation_prompt_context_clues_iter.next(), None); + } + #[test] fn test_root_folders_context_clues() { let mut root_folders_context_clues_iter = ROOT_FOLDERS_CONTEXT_CLUES.iter(); diff --git a/src/app/radarr/radarr_context_clues.rs b/src/app/radarr/radarr_context_clues.rs index 27a10af..4f92313 100644 --- a/src/app/radarr/radarr_context_clues.rs +++ b/src/app/radarr/radarr_context_clues.rs @@ -66,11 +66,6 @@ pub static ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.esc, "edit search"), ]; -pub static CONFIRMATION_PROMPT_CONTEXT_CLUES: [ContextClue; 2] = [ - (DEFAULT_KEYBINDINGS.confirm, "submit"), - (DEFAULT_KEYBINDINGS.esc, "cancel"), -]; - pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.submit, "start task"), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), diff --git a/src/app/radarr/radarr_context_clues_tests.rs b/src/app/radarr/radarr_context_clues_tests.rs index 8aa0173..4cebc74 100644 --- a/src/app/radarr/radarr_context_clues_tests.rs +++ b/src/app/radarr/radarr_context_clues_tests.rs @@ -5,7 +5,7 @@ mod tests { use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::radarr::radarr_context_clues::{ ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES, - COLLECTION_DETAILS_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, + COLLECTION_DETAILS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES, }; @@ -213,22 +213,6 @@ mod tests { assert_eq!(add_movie_search_results_context_clues_iter.next(), None); } - #[test] - fn test_confirmation_prompt_context_clues() { - let mut confirmation_prompt_context_clues_iter = CONFIRMATION_PROMPT_CONTEXT_CLUES.iter(); - - let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.confirm); - assert_str_eq!(*description, "submit"); - - let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); - assert_str_eq!(*description, "cancel"); - assert_eq!(confirmation_prompt_context_clues_iter.next(), None); - } - #[test] fn test_system_tasks_context_clues() { let mut system_tasks_context_clues_iter = SYSTEM_TASKS_CONTEXT_CLUES.iter(); diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 0cefe77..9b6a30b 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -108,6 +108,11 @@ impl<'a> App<'a> { .dispatch_network_event(SonarrEvent::GetLogs(None).into()) .await; } + ActiveSonarrBlock::AddSeriesSearchResults => { + self + .dispatch_network_event(SonarrEvent::SearchNewSeries(None).into()) + .await; + } ActiveSonarrBlock::SystemUpdates => { self .dispatch_network_event(SonarrEvent::GetUpdates.into()) diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 2a7ceab..ee311ec 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -4,6 +4,11 @@ use crate::app::{context_clues::ContextClue, key_binding::DEFAULT_KEYBINDINGS}; #[path = "sonarr_context_clues_tests.rs"] mod sonarr_context_clues_tests; +pub static ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, "edit search"), +]; + pub static SERIES_CONTEXT_CLUES: [ContextClue; 10] = [ (DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc), (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 6f93de0..d25992c 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -5,13 +5,30 @@ mod tests { use crate::app::{ key_binding::DEFAULT_KEYBINDINGS, sonarr::sonarr_context_clues::{ - EPISODE_DETAILS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, - MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, - MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, - SERIES_DETAILS_CONTEXT_CLUES, + ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, + HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, + MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, + SEASON_DETAILS_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, }, }; + #[test] + fn test_add_series_search_results_context_clues() { + let mut add_series_search_results_context_clues_iter = + ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES.iter(); + + let (key_binding, description) = add_series_search_results_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "details"); + + let (key_binding, description) = add_series_search_results_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, "edit search"); + assert_eq!(add_series_search_results_context_clues_iter.next(), None); + } + #[test] fn test_series_context_clues() { let mut series_context_clues_iter = SERIES_CONTEXT_CLUES.iter(); diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 13b743f..a02c217 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -348,6 +348,23 @@ mod tests { assert_eq!(app.tick_count, 0); } + #[tokio::test] + async fn test_dispatch_by_add_movie_search_results_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::AddSeriesSearchResults) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::SearchNewSeries(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + #[tokio::test] async fn test_check_for_sonarr_prompt_action_no_prompt_confirm() { let mut app = App::default(); diff --git a/src/ui/radarr_ui/collections/edit_collection_ui.rs b/src/ui/radarr_ui/collections/edit_collection_ui.rs index 7827427..decb857 100644 --- a/src/ui/radarr_ui/collections/edit_collection_ui.rs +++ b/src/ui/radarr_ui/collections/edit_collection_ui.rs @@ -5,8 +5,7 @@ use ratatui::text::Text; use ratatui::widgets::{ListItem, Paragraph}; use ratatui::Frame; -use crate::app::context_clues::build_context_clue_string; -use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES; +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::app::App; use crate::models::servarr_data::radarr::modals::EditCollectionModal; use crate::models::servarr_data::radarr::radarr_data::{ diff --git a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs index 9e681a1..085501c 100644 --- a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs +++ b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs @@ -1,7 +1,6 @@ use std::sync::atomic::Ordering; -use crate::app::context_clues::build_context_clue_string; -use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES; +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::app::App; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; use crate::models::Route; diff --git a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs index 34affd1..27907b3 100644 --- a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs +++ b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs @@ -5,8 +5,7 @@ use ratatui::text::Text; use ratatui::widgets::Paragraph; use ratatui::Frame; -use crate::app::context_clues::build_context_clue_string; -use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES; +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::app::App; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, INDEXER_SETTINGS_BLOCKS, diff --git a/src/ui/radarr_ui/library/add_movie_ui.rs b/src/ui/radarr_ui/library/add_movie_ui.rs index 31009ec..11cd8a4 100644 --- a/src/ui/radarr_ui/library/add_movie_ui.rs +++ b/src/ui/radarr_ui/library/add_movie_ui.rs @@ -5,10 +5,10 @@ use ratatui::text::Text; use ratatui::widgets::{Cell, ListItem, Paragraph, Row}; use ratatui::Frame; -use crate::app::context_clues::{build_context_clue_string, BARE_POPUP_CONTEXT_CLUES}; -use crate::app::radarr::radarr_context_clues::{ - ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, +use crate::app::context_clues::{ + build_context_clue_string, BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, }; +use crate::app::radarr::radarr_context_clues::ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES; use crate::models::radarr_models::AddMovieSearchResult; use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ADD_MOVIE_BLOCKS}; diff --git a/src/ui/radarr_ui/library/edit_movie_ui.rs b/src/ui/radarr_ui/library/edit_movie_ui.rs index b923833..972fffe 100644 --- a/src/ui/radarr_ui/library/edit_movie_ui.rs +++ b/src/ui/radarr_ui/library/edit_movie_ui.rs @@ -6,8 +6,7 @@ use ratatui::text::Text; use ratatui::widgets::{ListItem, Paragraph}; use ratatui::Frame; -use crate::app::context_clues::build_context_clue_string; -use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES; +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::app::App; use crate::models::servarr_data::radarr::modals::EditMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ diff --git a/src/ui/sonarr_ui/library/add_series_ui.rs b/src/ui/sonarr_ui/library/add_series_ui.rs new file mode 100644 index 0000000..9469864 --- /dev/null +++ b/src/ui/sonarr_ui/library/add_series_ui.rs @@ -0,0 +1,486 @@ +use std::sync::atomic::Ordering; + +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::text::Text; +use ratatui::widgets::{Cell, ListItem, Paragraph, Row}; +use ratatui::Frame; + +use crate::app::context_clues::{ + build_context_clue_string, BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, +}; +use crate::app::sonarr::sonarr_context_clues::ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES; +use crate::models::servarr_data::sonarr::modals::AddSeriesModal; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS}; +use crate::models::sonarr_models::AddSeriesSearchResult; +use crate::models::{EnumDisplayStyle, Route}; +use crate::ui::sonarr_ui::library::draw_library; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{ + borderless_block, get_width_from_percentage, layout_block, layout_paragraph_borderless, + title_block_centered, +}; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{draw_popup_over, DrawUi}; +use crate::{render_selectable_input_box, App}; + +#[cfg(test)] +#[path = "add_series_ui_tests.rs"] +mod add_series_ui_tests; + +pub(super) struct AddSeriesUi; + +impl DrawUi for AddSeriesUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return ADD_SERIES_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let draw_add_series_search_popup = + |f: &mut Frame<'_>, app: &mut App<'_>, area: Rect| match active_sonarr_block { + ActiveSonarrBlock::AddSeriesSearchInput + | ActiveSonarrBlock::AddSeriesSearchResults + | ActiveSonarrBlock::AddSeriesEmptySearchResults => { + draw_add_series_search(f, app, area); + } + ActiveSonarrBlock::AddSeriesPrompt + | ActiveSonarrBlock::AddSeriesSelectMonitor + | ActiveSonarrBlock::AddSeriesSelectSeriesType + | ActiveSonarrBlock::AddSeriesSelectQualityProfile + | ActiveSonarrBlock::AddSeriesSelectLanguageProfile + | ActiveSonarrBlock::AddSeriesSelectRootFolder + | ActiveSonarrBlock::AddSeriesTagsInput => { + draw_popup_over( + f, + app, + area, + draw_add_series_search, + draw_confirmation_popup, + Size::Long, + ); + } + ActiveSonarrBlock::AddSeriesAlreadyInLibrary => { + draw_add_series_search(f, app, area); + f.render_widget( + Popup::new(Message::new("This film is already in your library")).size(Size::Message), + f.area(), + ); + } + _ => (), + }; + + match active_sonarr_block { + _ if ADD_SERIES_BLOCKS.contains(&active_sonarr_block) => draw_popup_over( + f, + app, + area, + draw_library, + draw_add_series_search_popup, + Size::Large, + ), + _ => (), + } + } + } +} + +fn draw_add_series_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let is_loading = app.is_loading || app.data.sonarr_data.add_searched_series.is_none(); + let current_selection = + if let Some(add_searched_series) = app.data.sonarr_data.add_searched_series.as_ref() { + add_searched_series.current_selection().clone() + } else { + AddSeriesSearchResult::default() + }; + + let [search_box_area, results_area, help_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Fill(0), + Constraint::Length(3), + ]) + .margin(1) + .areas(area); + let block_content = &app + .data + .sonarr_data + .add_series_search + .as_ref() + .unwrap() + .text; + let offset = app + .data + .sonarr_data + .add_series_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst); + let search_results_row_mapping = |series: &AddSeriesSearchResult| { + let rating = series.ratings.clone().unwrap_or_default().value; + let series_rating = if rating == 0.0 { + String::new() + } else { + format!("{rating:.1}") + }; + let in_library = if app + .data + .sonarr_data + .series + .items + .iter() + .any(|mov| mov.tvdb_id == series.tvdb_id) + { + "✔" + } else { + "" + }; + let network = series.network.clone().unwrap_or_default(); + let seasons = if let Some(ref stats) = series.statistics { + format!("{}", stats.season_count) + } else { + String::new() + }; + + series.title.scroll_left_or_reset( + get_width_from_percentage(area, 27), + *series == current_selection, + app.tick_count % app.ticks_until_scroll == 0, + ); + + Row::new(vec![ + Cell::from(in_library), + Cell::from(series.title.to_string()), + Cell::from(series.year.to_string()), + Cell::from(network), + Cell::from(series_rating), + Cell::from(seasons), + Cell::from(series.genres.join(", ")), + ]) + .primary() + }; + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + match active_sonarr_block { + ActiveSonarrBlock::AddSeriesSearchInput => { + let search_box = InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Series")); + let help_text = Text::from(build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text) + .block(borderless_block()) + .centered(); + + search_box.show_cursor(f, search_box_area); + f.render_widget(layout_block(), results_area); + f.render_widget(search_box, search_box_area); + f.render_widget(help_paragraph, help_area); + } + ActiveSonarrBlock::AddSeriesEmptySearchResults => { + let help_text = Text::from(build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text) + .block(borderless_block()) + .centered(); + let error_message = Message::new("No series found matching your query!"); + let error_message_popup = Popup::new(error_message).size(Size::Message); + + f.render_widget(layout_block(), results_area); + f.render_widget(error_message_popup, f.area()); + f.render_widget(help_paragraph, help_area); + } + ActiveSonarrBlock::AddSeriesSearchResults + | ActiveSonarrBlock::AddSeriesPrompt + | ActiveSonarrBlock::AddSeriesSelectMonitor + | ActiveSonarrBlock::AddSeriesSelectSeriesType + | ActiveSonarrBlock::AddSeriesSelectQualityProfile + | ActiveSonarrBlock::AddSeriesSelectLanguageProfile + | ActiveSonarrBlock::AddSeriesSelectRootFolder + | ActiveSonarrBlock::AddSeriesAlreadyInLibrary + | ActiveSonarrBlock::AddSeriesTagsInput => { + let help_text = + Text::from(build_context_clue_string(&ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text) + .block(borderless_block()) + .centered(); + let search_results_table = ManagarrTable::new( + app.data.sonarr_data.add_searched_series.as_mut(), + search_results_row_mapping, + ) + .loading(is_loading) + .block(layout_block()) + .headers([ + "✔", "Title", "Year", "Network", "Seasons", "Rating", "Genres", + ]) + .constraints([ + Constraint::Percentage(2), + Constraint::Percentage(27), + Constraint::Percentage(9), + Constraint::Percentage(13), + Constraint::Percentage(9), + Constraint::Percentage(9), + Constraint::Percentage(28), + ]); + + f.render_widget(search_results_table, results_area); + f.render_widget(help_paragraph, help_area); + } + _ => (), + } + } + + f.render_widget( + InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Series")), + search_box_area, + ); +} + +fn draw_confirmation_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + match active_sonarr_block { + ActiveSonarrBlock::AddSeriesSelectMonitor => { + draw_confirmation_prompt(f, app, area); + draw_add_series_select_monitor_popup(f, app); + } + ActiveSonarrBlock::AddSeriesSelectSeriesType => { + draw_confirmation_prompt(f, app, area); + draw_add_series_select_series_type_popup(f, app); + } + ActiveSonarrBlock::AddSeriesSelectQualityProfile => { + draw_confirmation_prompt(f, app, area); + draw_add_series_select_quality_profile_popup(f, app); + } + ActiveSonarrBlock::AddSeriesSelectLanguageProfile => { + draw_confirmation_prompt(f, app, area); + draw_add_series_select_language_profile_popup(f, app); + } + ActiveSonarrBlock::AddSeriesSelectRootFolder => { + draw_confirmation_prompt(f, app, area); + draw_add_series_select_root_folder_popup(f, app); + } + ActiveSonarrBlock::AddSeriesPrompt | ActiveSonarrBlock::AddSeriesTagsInput => { + draw_confirmation_prompt(f, app, area) + } + _ => (), + } + } +} + +fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let (series_title, series_overview) = ( + &app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .title + .text, + app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .overview + .clone() + .unwrap_or_default(), + ); + let title = format!("Add Series - {series_title}"); + let prompt = series_overview; + let yes_no_value = app.data.sonarr_data.prompt_confirm; + let selected_block = app.data.sonarr_data.selected_block.get_active_block(); + let highlight_yes_no = selected_block == ActiveSonarrBlock::AddSeriesConfirmPrompt; + let AddSeriesModal { + monitor_list, + series_type_list, + quality_profile_list, + language_profile_list, + root_folder_list, + use_season_folder, + tags, + .. + } = app.data.sonarr_data.add_series_modal.as_ref().unwrap(); + + let selected_monitor = monitor_list.current_selection(); + let selected_series_type = series_type_list.current_selection(); + let selected_quality_profile = quality_profile_list.current_selection(); + let selected_language_profile = language_profile_list.current_selection(); + let selected_root_folder = root_folder_list.current_selection(); + + f.render_widget(title_block_centered(&title), area); + + let [paragraph_area, root_folder_area, monitor_area, quality_profile_area, language_profile_area, series_type_area, season_folder_area, tags_area, _, buttons_area, help_area] = + Layout::vertical([ + Constraint::Length(7), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .margin(1) + .areas(area); + + let prompt_paragraph = layout_paragraph_borderless(&prompt); + let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text).centered(); + f.render_widget(prompt_paragraph, paragraph_area); + f.render_widget(help_paragraph, help_area); + + let [add_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(buttons_area); + + let use_season_folder_checkbox = Checkbox::new("Season Folder") + .checked(*use_season_folder) + .highlighted(selected_block == ActiveSonarrBlock::AddSeriesToggleUseSeasonFolder); + let root_folder_drop_down_button = Button::new() + .title(&selected_root_folder.path) + .label("Root Folder") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectRootFolder); + let monitor_drop_down_button = Button::new() + .title(selected_monitor.to_display_str()) + .label("Monitor") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectMonitor); + let series_type_drop_down_button = Button::new() + .title(selected_series_type.to_display_str()) + .label("Series Type") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectSeriesType); + let quality_profile_drop_down_button = Button::new() + .title(selected_quality_profile) + .label("Quality Profile") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectQualityProfile); + let language_profile_drop_down_button = Button::new() + .title(selected_language_profile) + .label("Language Profile") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectLanguageProfile); + + f.render_widget(root_folder_drop_down_button, root_folder_area); + f.render_widget(monitor_drop_down_button, monitor_area); + f.render_widget(quality_profile_drop_down_button, quality_profile_area); + f.render_widget(language_profile_drop_down_button, language_profile_area); + f.render_widget(series_type_drop_down_button, series_type_area); + f.render_widget(use_season_folder_checkbox, season_folder_area); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let tags_input_box = InputBox::new(&tags.text) + .offset(tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(selected_block == ActiveSonarrBlock::AddSeriesTagsInput) + .selected(active_sonarr_block == ActiveSonarrBlock::AddSeriesTagsInput); + render_selectable_input_box!(tags_input_box, f, tags_area); + } + + let add_button = Button::new() + .title("Add") + .selected(yes_no_value && highlight_yes_no); + let cancel_button = Button::new() + .title("Cancel") + .selected(!yes_no_value && highlight_yes_no); + + f.render_widget(add_button, add_area); + f.render_widget(cancel_button, cancel_area); +} + +fn draw_add_series_select_monitor_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_list = SelectableList::new( + &mut app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .monitor_list, + |monitor| ListItem::new(monitor.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_series_select_series_type_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let series_type_list = SelectableList::new( + &mut app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .series_type_list, + |series_type| ListItem::new(series_type.to_display_str().to_owned()), + ); + let popup = Popup::new(series_type_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_series_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( + &mut app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .quality_profile_list, + |quality_profile| ListItem::new(quality_profile.clone()), + ); + let popup = Popup::new(quality_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_series_select_language_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let language_profile_list = SelectableList::new( + &mut app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .language_profile_list, + |language_profile| ListItem::new(language_profile.clone()), + ); + let popup = Popup::new(language_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_series_select_root_folder_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let root_folder_list = SelectableList::new( + &mut app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .root_folder_list, + |root_folder| ListItem::new(root_folder.path.to_owned()), + ); + let popup = Popup::new(root_folder_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} diff --git a/src/ui/sonarr_ui/library/add_series_ui_tests.rs b/src/ui/sonarr_ui/library/add_series_ui_tests.rs new file mode 100644 index 0000000..6bd15cd --- /dev/null +++ b/src/ui/sonarr_ui/library/add_series_ui_tests.rs @@ -0,0 +1,19 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS}; + use crate::ui::sonarr_ui::library::add_series_ui::AddSeriesUi; + use crate::ui::DrawUi; + + #[test] + fn test_add_series_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if ADD_SERIES_BLOCKS.contains(&active_sonarr_block) { + assert!(AddSeriesUi::accepts(active_sonarr_block.into())); + } else { + assert!(!AddSeriesUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index c94ea65..a94e582 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -1,6 +1,8 @@ #[cfg(test)] mod tests { - use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, + }; use crate::models::{ servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, }; @@ -21,6 +23,7 @@ mod tests { fn test_library_ui_accepts() { let mut library_ui_blocks = Vec::new(); library_ui_blocks.extend(LIBRARY_BLOCKS); + library_ui_blocks.extend(ADD_SERIES_BLOCKS); library_ui_blocks.extend(DELETE_SERIES_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_radarr_block| { diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index e25027f..1dc6c6a 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -1,3 +1,4 @@ +use add_series_ui::AddSeriesUi; use delete_series_ui::DeleteSeriesUi; use ratatui::{ layout::{Constraint, Rect}, @@ -26,6 +27,7 @@ use crate::{ }, }; +mod add_series_ui; mod delete_series_ui; #[cfg(test)] @@ -37,7 +39,9 @@ pub(super) struct LibraryUi; impl DrawUi for LibraryUi { fn accepts(route: Route) -> bool { if let Route::Sonarr(active_sonarr_block, _) = route { - return DeleteSeriesUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_sonarr_block); + return AddSeriesUi::accepts(route) + || DeleteSeriesUi::accepts(route) + || LIBRARY_BLOCKS.contains(&active_sonarr_block); } false @@ -90,6 +94,7 @@ impl DrawUi for LibraryUi { }; match route { + _ if AddSeriesUi::accepts(route) => AddSeriesUi::draw(f, app, area), _ if DeleteSeriesUi::accepts(route) => DeleteSeriesUi::draw(f, app, area), Route::Sonarr(active_sonarr_block, _) if LIBRARY_BLOCKS.contains(&active_sonarr_block) => { series_ui_matchers(active_sonarr_block) diff --git a/src/ui/widgets/confirmation_prompt.rs b/src/ui/widgets/confirmation_prompt.rs index 521f638..911bcaa 100644 --- a/src/ui/widgets/confirmation_prompt.rs +++ b/src/ui/widgets/confirmation_prompt.rs @@ -1,5 +1,4 @@ -use crate::app::context_clues::build_context_clue_string; -use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES; +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{layout_paragraph_borderless, title_block_centered}; use crate::ui::widgets::button::Button; diff --git a/src/ui/widgets/popup.rs b/src/ui/widgets/popup.rs index 0716918..4607cf0 100644 --- a/src/ui/widgets/popup.rs +++ b/src/ui/widgets/popup.rs @@ -21,6 +21,7 @@ pub enum Size { Small, Medium, Large, + Long, } impl Size { @@ -37,6 +38,7 @@ impl Size { Size::Small => (40, 40), Size::Medium => (60, 60), Size::Large => (75, 75), + Size::Long => (65, 80), } } } diff --git a/src/ui/widgets/popup_tests.rs b/src/ui/widgets/popup_tests.rs index 8b68cde..ea18bfe 100644 --- a/src/ui/widgets/popup_tests.rs +++ b/src/ui/widgets/popup_tests.rs @@ -17,6 +17,7 @@ mod tests { assert_eq!(Size::Small.to_percent(), (40, 40)); assert_eq!(Size::Medium.to_percent(), (60, 60)); assert_eq!(Size::Large.to_percent(), (75, 75)); + assert_eq!(Size::Long.to_percent(), (65, 80)); } #[test]