From 2d2901f6dc8718c9469c6650ba2ca774ca510d5f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 4 Dec 2024 16:39:37 -0700 Subject: [PATCH] feat(ui): Full Sonarr support for the indexer tab --- src/ui/radarr_ui/blocklist/mod.rs | 5 +- src/ui/radarr_ui/collections/mod.rs | 5 +- src/ui/radarr_ui/downloads/mod.rs | 10 +- src/ui/radarr_ui/indexers/edit_indexer_ui.rs | 2 +- .../radarr_ui/indexers/indexer_settings_ui.rs | 5 +- src/ui/radarr_ui/indexers/mod.rs | 5 +- src/ui/radarr_ui/library/delete_movie_ui.rs | 5 +- src/ui/radarr_ui/library/mod.rs | 5 +- src/ui/radarr_ui/library/movie_details_ui.rs | 15 +- src/ui/radarr_ui/root_folders/mod.rs | 5 +- src/ui/radarr_ui/system/system_details_ui.rs | 5 +- src/ui/sonarr_ui/blocklist/mod.rs | 5 +- src/ui/sonarr_ui/downloads/mod.rs | 10 +- src/ui/sonarr_ui/indexers/edit_indexer_ui.rs | 185 ++++++++++++++++++ .../indexers/edit_indexer_ui_tests.rs | 18 ++ .../sonarr_ui/indexers/indexer_settings_ui.rs | 127 ++++++++++++ .../indexers/indexer_settings_ui_tests.rs | 21 ++ .../sonarr_ui/indexers/indexers_ui_tests.rs | 27 +++ src/ui/sonarr_ui/indexers/mod.rs | 185 ++++++++++++++++++ .../indexers/test_all_indexers_ui.rs | 92 +++++++++ .../indexers/test_all_indexers_ui_tests.rs | 19 ++ src/ui/sonarr_ui/library/delete_series_ui.rs | 5 +- src/ui/sonarr_ui/library/mod.rs | 5 +- src/ui/sonarr_ui/mod.rs | 3 + src/ui/sonarr_ui/root_folders/mod.rs | 5 +- src/ui/widgets/popup.rs | 8 +- src/ui/widgets/popup_tests.rs | 5 +- 27 files changed, 761 insertions(+), 26 deletions(-) create mode 100644 src/ui/sonarr_ui/indexers/edit_indexer_ui.rs create mode 100644 src/ui/sonarr_ui/indexers/edit_indexer_ui_tests.rs create mode 100644 src/ui/sonarr_ui/indexers/indexer_settings_ui.rs create mode 100644 src/ui/sonarr_ui/indexers/indexer_settings_ui_tests.rs create mode 100644 src/ui/sonarr_ui/indexers/indexers_ui_tests.rs create mode 100644 src/ui/sonarr_ui/indexers/mod.rs create mode 100644 src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs create mode 100644 src/ui/sonarr_ui/indexers/test_all_indexers_ui_tests.rs diff --git a/src/ui/radarr_ui/blocklist/mod.rs b/src/ui/radarr_ui/blocklist/mod.rs index ff9f0ae..65bf10f 100644 --- a/src/ui/radarr_ui/blocklist/mod.rs +++ b/src/ui/radarr_ui/blocklist/mod.rs @@ -56,7 +56,10 @@ impl DrawUi for BlocklistUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_blocklist_table(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveRadarrBlock::BlocklistClearAllItemsPrompt => { let confirmation_prompt = ConfirmationPrompt::new() diff --git a/src/ui/radarr_ui/collections/mod.rs b/src/ui/radarr_ui/collections/mod.rs index e0f1bcd..8b033a3 100644 --- a/src/ui/radarr_ui/collections/mod.rs +++ b/src/ui/radarr_ui/collections/mod.rs @@ -81,7 +81,10 @@ impl DrawUi for CollectionsUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_collections(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), }; diff --git a/src/ui/radarr_ui/downloads/mod.rs b/src/ui/radarr_ui/downloads/mod.rs index 8d61bc6..da8724b 100644 --- a/src/ui/radarr_ui/downloads/mod.rs +++ b/src/ui/radarr_ui/downloads/mod.rs @@ -44,7 +44,10 @@ impl DrawUi for DownloadsUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_downloads(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveRadarrBlock::UpdateDownloadsPrompt => { let confirmation_prompt = ConfirmationPrompt::new() @@ -53,7 +56,10 @@ impl DrawUi for DownloadsUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_downloads(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), } diff --git a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs index 2aa8f64..d118a32 100644 --- a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs +++ b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs @@ -41,7 +41,7 @@ impl DrawUi for EditIndexerUi { area, draw_indexers, draw_edit_indexer_prompt, - Size::LargePrompt, + Size::WideLargePrompt, ); } } diff --git a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs index 27907b3..642efde 100644 --- a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs +++ b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs @@ -44,7 +44,7 @@ impl DrawUi for IndexerSettingsUi { area, draw_indexers, draw_edit_indexer_settings_prompt, - Size::LargePrompt, + Size::WideLargePrompt, ); } } @@ -61,7 +61,8 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: if indexer_settings_option.is_some() { let indexer_settings = indexer_settings_option.as_ref().unwrap(); - let [settings_area, _, buttons_area, help_area] = Layout::vertical([ + let [_, settings_area, _, buttons_area, help_area] = Layout::vertical([ + Constraint::Fill(1), Constraint::Length(15), Constraint::Fill(1), Constraint::Length(3), diff --git a/src/ui/radarr_ui/indexers/mod.rs b/src/ui/radarr_ui/indexers/mod.rs index aaa4193..9b1cbbf 100644 --- a/src/ui/radarr_ui/indexers/mod.rs +++ b/src/ui/radarr_ui/indexers/mod.rs @@ -86,7 +86,10 @@ impl DrawUi for IndexersUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_indexers(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), }; diff --git a/src/ui/radarr_ui/library/delete_movie_ui.rs b/src/ui/radarr_ui/library/delete_movie_ui.rs index 4cdc52f..c5d1090 100644 --- a/src/ui/radarr_ui/library/delete_movie_ui.rs +++ b/src/ui/radarr_ui/library/delete_movie_ui.rs @@ -51,7 +51,10 @@ impl DrawUi for DeleteMovieUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_library(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } } } diff --git a/src/ui/radarr_ui/library/mod.rs b/src/ui/radarr_ui/library/mod.rs index 500a4ea..a343b44 100644 --- a/src/ui/radarr_ui/library/mod.rs +++ b/src/ui/radarr_ui/library/mod.rs @@ -84,7 +84,10 @@ impl DrawUi for LibraryUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_library(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), }; diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index 819e43c..2b2bd47 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -61,7 +61,10 @@ impl DrawUi for MovieDetailsUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_movie_info(f, app, content_area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveRadarrBlock::UpdateAndScanPrompt => { let prompt = format!( @@ -73,7 +76,10 @@ impl DrawUi for MovieDetailsUi { .prompt(&prompt) .yes_no_value(app.data.radarr_data.prompt_confirm); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveRadarrBlock::ManualSearchConfirmPrompt => { draw_manual_search_confirm_prompt(f, app); @@ -532,7 +538,10 @@ fn draw_manual_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { .prompt(&prompt) .yes_no_value(app.data.radarr_data.prompt_confirm); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } } diff --git a/src/ui/radarr_ui/root_folders/mod.rs b/src/ui/radarr_ui/root_folders/mod.rs index 97da280..04a74d0 100644 --- a/src/ui/radarr_ui/root_folders/mod.rs +++ b/src/ui/radarr_ui/root_folders/mod.rs @@ -52,7 +52,10 @@ impl DrawUi for RootFoldersUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_root_folders(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), } diff --git a/src/ui/radarr_ui/system/system_details_ui.rs b/src/ui/radarr_ui/system/system_details_ui.rs index 68ac459..6198c86 100644 --- a/src/ui/radarr_ui/system/system_details_ui.rs +++ b/src/ui/radarr_ui/system/system_details_ui.rs @@ -145,7 +145,10 @@ fn draw_tasks_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .prompt(&prompt) .yes_no_value(app.data.radarr_data.prompt_confirm); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } } diff --git a/src/ui/sonarr_ui/blocklist/mod.rs b/src/ui/sonarr_ui/blocklist/mod.rs index 74a2d52..c382007 100644 --- a/src/ui/sonarr_ui/blocklist/mod.rs +++ b/src/ui/sonarr_ui/blocklist/mod.rs @@ -56,7 +56,10 @@ impl DrawUi for BlocklistUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_blocklist_table(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveSonarrBlock::BlocklistClearAllItemsPrompt => { let confirmation_prompt = ConfirmationPrompt::new() diff --git a/src/ui/sonarr_ui/downloads/mod.rs b/src/ui/sonarr_ui/downloads/mod.rs index 4ecdd1f..d41c913 100644 --- a/src/ui/sonarr_ui/downloads/mod.rs +++ b/src/ui/sonarr_ui/downloads/mod.rs @@ -44,7 +44,10 @@ impl DrawUi for DownloadsUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_downloads(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveSonarrBlock::UpdateDownloadsPrompt => { let confirmation_prompt = ConfirmationPrompt::new() @@ -53,7 +56,10 @@ impl DrawUi for DownloadsUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_downloads(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), } diff --git a/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs b/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs new file mode 100644 index 0000000..038a077 --- /dev/null +++ b/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs @@ -0,0 +1,185 @@ +use std::sync::atomic::Ordering; + +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; +use crate::models::Route; +use crate::render_selectable_input_box; +use crate::ui::sonarr_ui::indexers::draw_indexers; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::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::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{draw_popup_over, DrawUi}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::text::Text; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +#[cfg(test)] +#[path = "edit_indexer_ui_tests.rs"] +mod edit_indexer_ui_tests; + +pub(super) struct EditIndexerUi; + +impl DrawUi for EditIndexerUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return EDIT_INDEXER_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_popup_over( + f, + app, + area, + draw_indexers, + draw_edit_indexer_prompt, + Size::WideLargePrompt, + ); + } +} + +fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let block = title_block_centered("Edit Indexer"); + 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::EditIndexerConfirmPrompt; + let edit_indexer_modal_option = &app.data.sonarr_data.edit_indexer_modal; + let protocol = &app.data.sonarr_data.indexers.current_selection().protocol; + let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text).centered(); + + if edit_indexer_modal_option.is_some() { + let edit_indexer_modal = edit_indexer_modal_option.as_ref().unwrap(); + + let [_, settings_area, _, buttons_area, help_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(18), + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .margin(1) + .areas(area); + let [left_side_area, right_side_area] = + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) + .margin(1) + .areas(settings_area); + let [name_area, rss_area, auto_search_area, interactive_search_area, priority_area] = + Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .areas(left_side_area); + let [url_area, api_key_area, seed_ratio_area, tags_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .areas(right_side_area); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let priority = edit_indexer_modal.priority.to_string(); + let name_input_box = InputBox::new(&edit_indexer_modal.name.text) + .offset(edit_indexer_modal.name.offset.load(Ordering::SeqCst)) + .label("Name") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerNameInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerNameInput); + let url_input_box = InputBox::new(&edit_indexer_modal.url.text) + .offset(edit_indexer_modal.url.offset.load(Ordering::SeqCst)) + .label("URL") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerUrlInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerUrlInput); + let api_key_input_box = InputBox::new(&edit_indexer_modal.api_key.text) + .offset(edit_indexer_modal.api_key.offset.load(Ordering::SeqCst)) + .label("API Key") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerApiKeyInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerApiKeyInput); + let tags_input_box = InputBox::new(&edit_indexer_modal.tags.text) + .offset(edit_indexer_modal.tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerTagsInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerTagsInput); + let priority_input_box = InputBox::new(&priority) + .cursor_after_string(false) + .label("Indexer Priority ▴▾") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerPriorityInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerPriorityInput); + + render_selectable_input_box!(name_input_box, f, name_area); + render_selectable_input_box!(url_input_box, f, url_area); + render_selectable_input_box!(api_key_input_box, f, api_key_area); + + if protocol == "torrent" { + let seed_ratio_input_box = InputBox::new(&edit_indexer_modal.seed_ratio.text) + .offset(edit_indexer_modal.seed_ratio.offset.load(Ordering::SeqCst)) + .label("Seed Ratio") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerSeedRatioInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerSeedRatioInput); + let tags_input_box = InputBox::new(&edit_indexer_modal.tags.text) + .offset(edit_indexer_modal.tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerTagsInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerTagsInput); + + render_selectable_input_box!(seed_ratio_input_box, f, seed_ratio_area); + render_selectable_input_box!(tags_input_box, f, tags_area); + render_selectable_input_box!(priority_input_box, f, priority_area); + } else { + render_selectable_input_box!(tags_input_box, f, seed_ratio_area); + render_selectable_input_box!(priority_input_box, f, tags_area); + } + + let rss_checkbox = Checkbox::new("Enable RSS") + .checked(edit_indexer_modal.enable_rss.unwrap_or_default()) + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerToggleEnableRss); + let auto_search_checkbox = Checkbox::new("Enable Automatic Search") + .checked( + edit_indexer_modal + .enable_automatic_search + .unwrap_or_default(), + ) + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch); + let interactive_search_checkbox = Checkbox::new("Enable Interactive Search") + .checked( + edit_indexer_modal + .enable_interactive_search + .unwrap_or_default(), + ) + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch); + + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(25)]) + .flex(Flex::Center) + .areas(buttons_area); + + let save_button = Button::new() + .title("Save") + .selected(yes_no_value && highlight_yes_no); + let cancel_button = Button::new() + .title("Cancel") + .selected(!yes_no_value && highlight_yes_no); + + f.render_widget(block, area); + f.render_widget(rss_checkbox, rss_area); + f.render_widget(auto_search_checkbox, auto_search_area); + f.render_widget(interactive_search_checkbox, interactive_search_area); + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); + f.render_widget(help_paragraph, help_area); + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} diff --git a/src/ui/sonarr_ui/indexers/edit_indexer_ui_tests.rs b/src/ui/sonarr_ui/indexers/edit_indexer_ui_tests.rs new file mode 100644 index 0000000..362bc61 --- /dev/null +++ b/src/ui/sonarr_ui/indexers/edit_indexer_ui_tests.rs @@ -0,0 +1,18 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; + use crate::ui::sonarr_ui::indexers::edit_indexer_ui::EditIndexerUi; + use crate::ui::DrawUi; + use strum::IntoEnumIterator; + + #[test] + fn test_edit_indexer_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if EDIT_INDEXER_BLOCKS.contains(&active_sonarr_block) { + assert!(EditIndexerUi::accepts(active_sonarr_block.into())); + } else { + assert!(!EditIndexerUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs b/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs new file mode 100644 index 0000000..684a15c --- /dev/null +++ b/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs @@ -0,0 +1,127 @@ +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::text::Text; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, INDEXER_SETTINGS_BLOCKS, +}; +use crate::models::Route; +use crate::render_selectable_input_box; +use crate::ui::sonarr_ui::indexers::draw_indexers; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{draw_popup_over, DrawUi}; + +#[cfg(test)] +#[path = "indexer_settings_ui_tests.rs"] +mod indexer_settings_ui_tests; + +pub(super) struct IndexerSettingsUi; + +impl DrawUi for IndexerSettingsUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return INDEXER_SETTINGS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_popup_over( + f, + app, + area, + draw_indexers, + draw_edit_indexer_settings_prompt, + Size::LargePrompt, + ); + } +} + +fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let block = title_block_centered("Configure All Indexer Settings"); + 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::IndexerSettingsConfirmPrompt; + let indexer_settings_option = &app.data.sonarr_data.indexer_settings; + let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text).centered(); + + if indexer_settings_option.is_some() { + let indexer_settings = indexer_settings_option.as_ref().unwrap(); + + let [_, min_age_area, retention_area, max_size_area, rss_sync_area, _, buttons_area, help_area] = + Layout::vertical([ + Constraint::Fill(1), + 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); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let min_age = indexer_settings.minimum_age.to_string(); + let retention = indexer_settings.retention.to_string(); + let max_size = indexer_settings.maximum_size.to_string(); + let rss_sync_interval = indexer_settings.rss_sync_interval.to_string(); + + let min_age_text_box = InputBox::new(&min_age) + .cursor_after_string(false) + .label("Minimum Age (minutes) ▴▾") + .highlighted(selected_block == ActiveSonarrBlock::IndexerSettingsMinimumAgeInput) + .selected(active_sonarr_block == ActiveSonarrBlock::IndexerSettingsMinimumAgeInput); + let retention_input_box = InputBox::new(&retention) + .cursor_after_string(false) + .label("Retention (days) ▴▾") + .highlighted(selected_block == ActiveSonarrBlock::IndexerSettingsRetentionInput) + .selected(active_sonarr_block == ActiveSonarrBlock::IndexerSettingsRetentionInput); + let max_size_input_box = InputBox::new(&max_size) + .cursor_after_string(false) + .label("Maximum Size (MB) ▴▾") + .highlighted(selected_block == ActiveSonarrBlock::IndexerSettingsMaximumSizeInput) + .selected(active_sonarr_block == ActiveSonarrBlock::IndexerSettingsMaximumSizeInput); + let rss_sync_interval_input_box = InputBox::new(&rss_sync_interval) + .cursor_after_string(false) + .label("RSS Sync Interval (minutes) ▴▾") + .highlighted(selected_block == ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput) + .selected(active_sonarr_block == ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput); + + render_selectable_input_box!(min_age_text_box, f, min_age_area); + render_selectable_input_box!(retention_input_box, f, retention_area); + render_selectable_input_box!(max_size_input_box, f, max_size_area); + render_selectable_input_box!(rss_sync_interval_input_box, f, rss_sync_area); + } + + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(25)]) + .flex(Flex::Center) + .areas(buttons_area); + + let save_button = Button::new() + .title("Save") + .selected(yes_no_value && highlight_yes_no); + let cancel_button = Button::new() + .title("Cancel") + .selected(!yes_no_value && highlight_yes_no); + + f.render_widget(block, area); + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); + f.render_widget(help_paragraph, help_area); + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} diff --git a/src/ui/sonarr_ui/indexers/indexer_settings_ui_tests.rs b/src/ui/sonarr_ui/indexers/indexer_settings_ui_tests.rs new file mode 100644 index 0000000..f95304f --- /dev/null +++ b/src/ui/sonarr_ui/indexers/indexer_settings_ui_tests.rs @@ -0,0 +1,21 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, INDEXER_SETTINGS_BLOCKS, + }; + use crate::ui::sonarr_ui::indexers::indexer_settings_ui::IndexerSettingsUi; + use crate::ui::DrawUi; + + #[test] + fn test_indexer_settings_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if INDEXER_SETTINGS_BLOCKS.contains(&active_sonarr_block) { + assert!(IndexerSettingsUi::accepts(active_sonarr_block.into())); + } else { + assert!(!IndexerSettingsUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs b/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs new file mode 100644 index 0000000..84a548d --- /dev/null +++ b/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs @@ -0,0 +1,27 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, EDIT_INDEXER_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, + }; + use crate::ui::sonarr_ui::indexers::IndexersUi; + use crate::ui::DrawUi; + + #[test] + fn test_indexers_ui_accepts() { + let mut indexers_blocks = Vec::new(); + indexers_blocks.extend(INDEXERS_BLOCKS); + indexers_blocks.extend(INDEXER_SETTINGS_BLOCKS); + indexers_blocks.extend(EDIT_INDEXER_BLOCKS); + indexers_blocks.push(ActiveSonarrBlock::TestAllIndexers); + + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if indexers_blocks.contains(&active_sonarr_block) { + assert!(IndexersUi::accepts(active_sonarr_block.into())); + } else { + assert!(!IndexersUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/indexers/mod.rs b/src/ui/sonarr_ui/indexers/mod.rs new file mode 100644 index 0000000..a560d9a --- /dev/null +++ b/src/ui/sonarr_ui/indexers/mod.rs @@ -0,0 +1,185 @@ +use ratatui::layout::{Constraint, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::Text; +use ratatui::widgets::{Cell, Row}; +use ratatui::Frame; + +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, INDEXERS_BLOCKS}; +use crate::models::servarr_models::Indexer; +use crate::models::Route; +use crate::ui::sonarr_ui::indexers::edit_indexer_ui::EditIndexerUi; +use crate::ui::sonarr_ui::indexers::indexer_settings_ui::IndexerSettingsUi; +use crate::ui::sonarr_ui::indexers::test_all_indexers_ui::TestAllIndexersUi; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{layout_block_top_border, title_block}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::DrawUi; + +mod edit_indexer_ui; +mod indexer_settings_ui; +mod test_all_indexers_ui; + +#[cfg(test)] +#[path = "indexers_ui_tests.rs"] +mod indexers_ui_tests; + +pub(super) struct IndexersUi; + +impl DrawUi for IndexersUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return EditIndexerUi::accepts(route) + || IndexerSettingsUi::accepts(route) + || TestAllIndexersUi::accepts(route) + || INDEXERS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); + let mut indexers_matchers = |active_sonarr_block| match active_sonarr_block { + ActiveSonarrBlock::Indexers => draw_indexers(f, app, area), + ActiveSonarrBlock::TestIndexer => { + draw_indexers(f, app, area); + if app.is_loading || app.is_routing { + let loading_popup = Popup::new(LoadingBlock::new( + app.is_loading, + title_block("Testing Indexer"), + )) + .size(Size::LargeMessage); + f.render_widget(loading_popup, f.area()); + } else { + let popup = if let Some(result) = app.data.sonarr_data.indexer_test_error.as_ref() { + Popup::new(Message::new(result.clone())).size(Size::LargeMessage) + } else { + let message = Message::new("Indexer test succeeded!") + .title("Success") + .style(Style::new().success().bold()); + Popup::new(message).size(Size::Message) + }; + + f.render_widget(popup, f.area()); + } + } + ActiveSonarrBlock::DeleteIndexerPrompt => { + let prompt = format!( + "Do you really want to delete this indexer: \n{}?", + app + .data + .sonarr_data + .indexers + .current_selection() + .name + .clone() + .unwrap_or_default() + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Indexer") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + draw_indexers(f, app, area); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), + }; + + match route { + _ if EditIndexerUi::accepts(route) => EditIndexerUi::draw(f, app, area), + _ if IndexerSettingsUi::accepts(route) => IndexerSettingsUi::draw(f, app, area), + _ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area), + Route::Sonarr(active_sonarr_block, _) if INDEXERS_BLOCKS.contains(&active_sonarr_block) => { + indexers_matchers(active_sonarr_block) + } + _ => (), + } + } +} + +fn draw_indexers(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let indexers_row_mapping = |indexer: &'_ Indexer| { + let Indexer { + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + priority, + tags, + .. + } = indexer; + let bool_to_text = |flag: bool| { + if flag { + return Text::from("Enabled").success(); + } + + Text::from("Disabled").failure() + }; + + let rss = bool_to_text(*enable_rss); + let automatic_search = bool_to_text(*enable_automatic_search); + let interactive_search = bool_to_text(*enable_interactive_search); + let tags: String = tags + .iter() + .map(|tag_id| { + app + .data + .sonarr_data + .tags_map + .get_by_left(&tag_id.as_i64().unwrap()) + .unwrap() + .clone() + }) + .collect::>() + .join(", "); + + Row::new(vec![ + Cell::from(name.clone().unwrap_or_default()), + Cell::from(rss), + Cell::from(automatic_search), + Cell::from(interactive_search), + Cell::from(priority.to_string()), + Cell::from(tags), + ]) + .primary() + }; + let indexers_table_footer = app + .data + .sonarr_data + .main_tabs + .get_active_tab_contextual_help(); + let indexers_table = ManagarrTable::new( + Some(&mut app.data.sonarr_data.indexers), + indexers_row_mapping, + ) + .block(layout_block_top_border()) + .footer(indexers_table_footer) + .loading(app.is_loading) + .headers([ + "Indexer", + "RSS", + "Automatic Search", + "Interactive Search", + "Priority", + "Tags", + ]) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(23), + ]); + + f.render_widget(indexers_table, area); +} diff --git a/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs new file mode 100644 index 0000000..0962a20 --- /dev/null +++ b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs @@ -0,0 +1,92 @@ +use crate::app::context_clues::{build_context_clue_string, BARE_POPUP_CONTEXT_CLUES}; +use crate::app::App; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; +use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use crate::models::Route; +use crate::ui::sonarr_ui::indexers::draw_indexers; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{borderless_block, get_width_from_percentage, title_block}; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::Size; +use crate::ui::{draw_popup_over, DrawUi}; +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; +use ratatui::Frame; + +#[cfg(test)] +#[path = "test_all_indexers_ui_tests.rs"] +mod test_all_indexers_ui_tests; + +pub(super) struct TestAllIndexersUi; + +impl DrawUi for TestAllIndexersUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return active_sonarr_block == ActiveSonarrBlock::TestAllIndexers; + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_popup_over( + f, + app, + area, + draw_indexers, + draw_test_all_indexers_test_results, + Size::Large, + ); + } +} + +fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = + if let Some(test_all_results) = app.data.sonarr_data.indexer_test_all_results.as_ref() { + test_all_results.current_selection().clone() + } else { + IndexerTestResultModalItem::default() + }; + f.render_widget(title_block("Test All Indexers"), area); + let help_footer = format!( + "<↑↓> scroll | {}", + build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES) + ); + let test_results_row_mapping = |result: &IndexerTestResultModalItem| { + result.validation_failures.scroll_left_or_reset( + get_width_from_percentage(area, 86), + *result == current_selection, + app.tick_count % app.ticks_until_scroll == 0, + ); + let pass_fail = if result.is_valid { "✔" } else { "❌" }; + let row = Row::new(vec![ + Cell::from(result.name.to_owned()), + Cell::from(pass_fail.to_owned()), + Cell::from(result.validation_failures.to_string()), + ]); + + if result.is_valid { + row.success() + } else { + row.failure() + } + }; + + let indexers_test_results_table = ManagarrTable::new( + app.data.sonarr_data.indexer_test_all_results.as_mut(), + test_results_row_mapping, + ) + .block(borderless_block()) + .loading(app.is_loading) + .footer(Some(help_footer)) + .footer_alignment(Alignment::Center) + .margin(1) + .headers(["Indexer", "Pass/Fail", "Failure Messages"]) + .constraints([ + Constraint::Percentage(20), + Constraint::Percentage(10), + Constraint::Percentage(70), + ]); + + f.render_widget(indexers_test_results_table, area); +} diff --git a/src/ui/sonarr_ui/indexers/test_all_indexers_ui_tests.rs b/src/ui/sonarr_ui/indexers/test_all_indexers_ui_tests.rs new file mode 100644 index 0000000..16f7e2e --- /dev/null +++ b/src/ui/sonarr_ui/indexers/test_all_indexers_ui_tests.rs @@ -0,0 +1,19 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::ui::sonarr_ui::indexers::test_all_indexers_ui::TestAllIndexersUi; + use crate::ui::DrawUi; + + #[test] + fn test_test_all_indexers_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { + assert!(TestAllIndexersUi::accepts(active_sonarr_block.into())); + } else { + assert!(!TestAllIndexersUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/library/delete_series_ui.rs b/src/ui/sonarr_ui/library/delete_series_ui.rs index eb7278e..c30c421 100644 --- a/src/ui/sonarr_ui/library/delete_series_ui.rs +++ b/src/ui/sonarr_ui/library/delete_series_ui.rs @@ -51,7 +51,10 @@ impl DrawUi for DeleteSeriesUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_library(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } } } diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 9a3d731..4ab9e25 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -91,7 +91,10 @@ impl DrawUi for LibraryUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_library(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), }; diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index ae7a097..4dc47f4 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -4,6 +4,7 @@ use blocklist::BlocklistUi; use chrono::{Duration, Utc}; use downloads::DownloadsUi; use history::HistoryUi; +use indexers::IndexersUi; use library::LibraryUi; use ratatui::{ layout::{Constraint, Layout, Rect}, @@ -39,6 +40,7 @@ use super::{ mod blocklist; mod downloads; mod history; +mod indexers; mod library; mod root_folders; @@ -63,6 +65,7 @@ impl DrawUi for SonarrUi { _ if BlocklistUi::accepts(route) => BlocklistUi::draw(f, app, content_area), _ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area), _ if RootFoldersUi::accepts(route) => RootFoldersUi::draw(f, app, content_area), + _ if IndexersUi::accepts(route) => IndexersUi::draw(f, app, content_area), _ => (), } } diff --git a/src/ui/sonarr_ui/root_folders/mod.rs b/src/ui/sonarr_ui/root_folders/mod.rs index ee564e3..d94b890 100644 --- a/src/ui/sonarr_ui/root_folders/mod.rs +++ b/src/ui/sonarr_ui/root_folders/mod.rs @@ -52,7 +52,10 @@ impl DrawUi for RootFoldersUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_root_folders(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), } diff --git a/src/ui/widgets/popup.rs b/src/ui/widgets/popup.rs index 0fcedd2..81c00f1 100644 --- a/src/ui/widgets/popup.rs +++ b/src/ui/widgets/popup.rs @@ -11,8 +11,9 @@ mod popup_tests; pub enum Size { SmallPrompt, - Prompt, + MediumPrompt, LargePrompt, + WideLargePrompt, Message, NarrowMessage, LargeMessage, @@ -28,8 +29,9 @@ impl Size { pub fn to_percent(&self) -> (u16, u16) { match self { Size::SmallPrompt => (20, 20), - Size::Prompt => (37, 37), - Size::LargePrompt => (70, 50), + Size::MediumPrompt => (37, 37), + Size::LargePrompt => (45, 45), + Size::WideLargePrompt => (70, 50), Size::Message => (25, 8), Size::NarrowMessage => (50, 20), Size::LargeMessage => (25, 25), diff --git a/src/ui/widgets/popup_tests.rs b/src/ui/widgets/popup_tests.rs index 1df6e11..2098ed0 100644 --- a/src/ui/widgets/popup_tests.rs +++ b/src/ui/widgets/popup_tests.rs @@ -7,8 +7,9 @@ mod tests { #[test] fn test_dimensions_to_percent() { assert_eq!(Size::SmallPrompt.to_percent(), (20, 20)); - assert_eq!(Size::Prompt.to_percent(), (37, 37)); - assert_eq!(Size::LargePrompt.to_percent(), (70, 50)); + assert_eq!(Size::MediumPrompt.to_percent(), (37, 37)); + assert_eq!(Size::LargePrompt.to_percent(), (45, 45)); + assert_eq!(Size::WideLargePrompt.to_percent(), (70, 50)); assert_eq!(Size::Message.to_percent(), (25, 8)); assert_eq!(Size::NarrowMessage.to_percent(), (50, 20)); assert_eq!(Size::LargeMessage.to_percent(), (25, 25));