From 6ba78cb4ba7afc666bf67bc1f708384432a609ba Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 12 Feb 2024 19:15:02 -0700 Subject: [PATCH] Refactored table sorting into the ManagarrTable widget and StatefulTable so any and all tables created can support sorting with minimal UI changes and thus only need to focus on the handlers. I'm going to continue this effort tomorrow and look at what other widgets can be created to simplify things. Most likely, prompt boxes. --- .../collections/collection_details_handler.rs | 3 +- .../collections/collections_handler_tests.rs | 4 +- .../test_all_indexers_handler_tests.rs | 7 +- .../library/add_movie_handler_tests.rs | 10 +- .../library/library_handler_tests.rs | 4 +- .../library/movie_details_handler.rs | 163 +-- .../library/movie_details_handler_tests.rs | 273 +++-- .../system/system_details_handler.rs | 3 +- src/models/mod.rs | 260 +---- src/models/model_tests.rs | 966 +----------------- src/models/radarr_models.rs | 16 +- src/models/servarr_data/radarr/modals.rs | 11 +- .../servarr_data/radarr/modals_tests.rs | 2 +- src/models/servarr_data/radarr/radarr_data.rs | 5 +- .../servarr_data/radarr/radarr_test_utils.rs | 9 +- src/models/stateful_list.rs | 95 ++ src/models/stateful_list_tests.rs | 111 ++ src/models/stateful_table.rs | 299 ++++++ src/models/stateful_table_tests.rs | 615 +++++++++++ src/network/radarr_network.rs | 3 +- src/network/radarr_network_tests.rs | 2 +- src/ui/mod.rs | 34 +- .../collections/edit_collection_ui.rs | 44 +- src/ui/radarr_ui/library/add_movie_ui.rs | 81 +- src/ui/radarr_ui/library/edit_movie_ui.rs | 45 +- src/ui/radarr_ui/library/movie_details_ui.rs | 238 ++--- src/ui/widgets/managarr_table.rs | 56 +- src/ui/widgets/mod.rs | 1 + src/ui/widgets/selectable_list.rs | 47 + 29 files changed, 1691 insertions(+), 1716 deletions(-) create mode 100644 src/models/stateful_list.rs create mode 100644 src/models/stateful_list_tests.rs create mode 100644 src/models/stateful_table.rs create mode 100644 src/models/stateful_table_tests.rs create mode 100644 src/ui/widgets/selectable_list.rs diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler.rs b/src/handlers/radarr_handlers/collections/collection_details_handler.rs index 8d4fa81..1e5e75e 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler.rs @@ -6,7 +6,8 @@ use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, ADD_MOVIE_SELECTION_BLOCKS, COLLECTION_DETAILS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS, }; -use crate::models::{BlockSelectionState, Scrollable, StatefulTable}; +use crate::models::stateful_table::StatefulTable; +use crate::models::{BlockSelectionState, Scrollable}; #[cfg(test)] #[path = "collection_details_handler_tests.rs"] diff --git a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs index bb192d5..fbbea91 100644 --- a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs @@ -607,7 +607,7 @@ mod tests { use ratatui::widgets::TableState; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; - use crate::models::StatefulTable; + use crate::models::stateful_table::StatefulTable; use super::*; @@ -733,7 +733,7 @@ mod tests { RadarrData, EDIT_COLLECTION_SELECTION_BLOCKS, }; - use crate::models::StatefulTable; + use crate::models::stateful_table::StatefulTable; use crate::{assert_refresh_key, test_edit_collection_key}; use super::*; diff --git a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs index 5413336..b7a50f9 100644 --- a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs @@ -13,7 +13,7 @@ mod tests { use rstest::rstest; use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem; - use crate::models::StatefulTable; + use crate::models::stateful_table::StatefulTable; use crate::simple_stateful_iterable_vec; use super::*; @@ -66,7 +66,7 @@ mod tests { mod test_handle_home_end { use crate::extended_stateful_iterable_vec; use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem; - use crate::models::StatefulTable; + use crate::models::stateful_table::StatefulTable; use pretty_assertions::assert_str_eq; use super::*; @@ -125,10 +125,9 @@ mod tests { } mod test_handle_esc { + use crate::models::stateful_table::StatefulTable; use pretty_assertions::assert_eq; - use crate::models::StatefulTable; - use super::*; #[test] diff --git a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs index 391f68b..7f3be59 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs @@ -21,7 +21,8 @@ mod tests { use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::ADD_MOVIE_SELECTION_BLOCKS; - use crate::models::{BlockSelectionState, StatefulTable}; + use crate::models::stateful_table::StatefulTable; + use crate::models::BlockSelectionState; use crate::simple_stateful_iterable_vec; use super::*; @@ -348,7 +349,7 @@ mod tests { use crate::extended_stateful_iterable_vec; use crate::models::servarr_data::radarr::modals::AddMovieModal; - use crate::models::StatefulTable; + use crate::models::stateful_table::StatefulTable; use super::*; @@ -852,7 +853,8 @@ mod tests { use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::ADD_MOVIE_SELECTION_BLOCKS; - use crate::models::{BlockSelectionState, StatefulTable}; + use crate::models::stateful_table::StatefulTable; + use crate::models::BlockSelectionState; use crate::network::radarr_network::RadarrEvent; use super::*; @@ -1146,7 +1148,7 @@ mod tests { use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; - use crate::models::StatefulTable; + use crate::models::stateful_table::StatefulTable; use crate::simple_stateful_iterable_vec; use super::*; diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index 246612b..0fdfb6a 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -586,7 +586,7 @@ mod tests { use ratatui::widgets::TableState; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; - use crate::models::StatefulTable; + use crate::models::stateful_table::StatefulTable; use super::*; @@ -694,7 +694,7 @@ mod tests { RadarrData, EDIT_MOVIE_SELECTION_BLOCKS, }; - use crate::models::StatefulTable; + use crate::models::stateful_table::StatefulTable; use crate::{assert_refresh_key, test_edit_movie_key}; use super::*; diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index 2397f95..955d32d 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -1,16 +1,14 @@ -use std::cmp::Ordering; - use serde_json::Number; -use strum::IntoEnumIterator; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; -use crate::models::radarr_models::{Language, Release, ReleaseField}; +use crate::models::radarr_models::{Language, Release}; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_MOVIE_SELECTION_BLOCKS, MOVIE_DETAILS_BLOCKS, }; +use crate::models::stateful_table::SortOption; use crate::models::{BlockSelectionState, Scrollable}; use crate::network::radarr_network::RadarrEvent; @@ -102,7 +100,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .movie_details_modal .as_mut() .unwrap() - .movie_releases_sort + .movie_releases + .sort + .as_mut() + .unwrap() .scroll_up(), _ => (), } @@ -162,7 +163,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .movie_details_modal .as_mut() .unwrap() - .movie_releases_sort + .movie_releases + .sort + .as_mut() + .unwrap() .scroll_down(), _ => (), } @@ -222,7 +226,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .movie_details_modal .as_mut() .unwrap() - .movie_releases_sort + .movie_releases + .sort + .as_mut() + .unwrap() .scroll_to_top(), _ => (), } @@ -282,7 +289,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .movie_details_modal .as_mut() .unwrap() - .movie_releases_sort + .movie_releases + .sort + .as_mut() + .unwrap() .scroll_to_bottom(), _ => (), } @@ -349,25 +359,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< self.app.pop_navigation_stack(); } ActiveRadarrBlock::ManualSearchSortPrompt => { - let movie_details_modal = self + self .app .data .radarr_data .movie_details_modal .as_mut() - .unwrap(); - let movie_releases = movie_details_modal.movie_releases.items.clone(); - let field = movie_details_modal.movie_releases_sort.current_selection(); - let sort_ascending = !movie_details_modal.sort_ascending.unwrap(); - movie_details_modal.sort_ascending = Some(sort_ascending); - - movie_details_modal + .unwrap() .movie_releases - .set_items(sort_releases_by_selected_field( - movie_releases, - *field, - sort_ascending, - )); + .apply_sorting(); self.app.pop_navigation_stack(); } _ => (), @@ -433,19 +433,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .pop_and_push_navigation_stack((*self.active_radarr_block).into()); } _ if *key == DEFAULT_KEYBINDINGS.sort.key => { - let movie_details_modal = self + self .app .data .radarr_data .movie_details_modal .as_mut() - .unwrap(); - movie_details_modal - .movie_releases_sort - .set_items(Vec::from_iter(ReleaseField::iter())); - let sort_ascending = movie_details_modal.sort_ascending; - movie_details_modal.sort_ascending = - Some(sort_ascending.is_some() && sort_ascending.unwrap()); + .unwrap() + .movie_releases + .sorting(releases_sorting_options()); self .app .push_navigation_stack(ActiveRadarrBlock::ManualSearchSortPrompt.into()); @@ -457,58 +453,67 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< } } -fn sort_releases_by_selected_field( - mut releases: Vec, - field: ReleaseField, - sort_ascending: bool, -) -> Vec { - let cmp_fn: fn(&Release, &Release) -> Ordering = match field { - ReleaseField::Source => |release_a, release_b| release_a.protocol.cmp(&release_b.protocol), - ReleaseField::Age => |release_a, release_b| release_a.age.cmp(&release_b.age), - ReleaseField::Rejected => |release_a, release_b| release_a.rejected.cmp(&release_b.rejected), - ReleaseField::Title => |release_a, release_b| release_a.title.text.cmp(&release_b.title.text), - ReleaseField::Indexer => |release_a, release_b| release_a.indexer.cmp(&release_b.indexer), - ReleaseField::Size => |release_a, release_b| release_a.size.cmp(&release_b.size), - ReleaseField::Peers => |release_a, release_b| { - let default_number = Number::from(i64::MAX); - let seeder_a = release_a - .seeders - .as_ref() - .unwrap_or(&default_number) - .as_u64() - .unwrap(); - let seeder_b = release_b - .seeders - .as_ref() - .unwrap_or(&default_number) - .as_u64() - .unwrap(); - - seeder_a.cmp(&seeder_b) +fn releases_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Source", + cmp_fn: Some(|a, b| a.protocol.cmp(&b.protocol)), }, - ReleaseField::Language => |release_a, release_b| { - let default_language_vec = vec![Language { - name: "_".to_owned(), - }]; - let language_a = &release_a - .languages - .as_ref() - .unwrap_or(&default_language_vec)[0]; - let language_b = &release_b - .languages - .as_ref() - .unwrap_or(&default_language_vec)[0]; - - language_a.cmp(language_b) + SortOption { + name: "Age", + cmp_fn: Some(|a, b| a.age.cmp(&b.age)), }, - ReleaseField::Quality => |release_a, release_b| release_a.quality.cmp(&release_b.quality), - }; + SortOption { + name: "Rejected", + cmp_fn: Some(|a, b| a.rejected.cmp(&b.rejected)), + }, + SortOption { + name: "Title", + cmp_fn: Some(|a, b| a.title.text.cmp(&b.title.text)), + }, + SortOption { + name: "Indexer", + cmp_fn: Some(|a, b| a.indexer.cmp(&b.indexer)), + }, + SortOption { + name: "Size", + cmp_fn: Some(|a, b| a.size.cmp(&b.size)), + }, + SortOption { + name: "Peers", + cmp_fn: Some(|a, b| { + let default_number = Number::from(i64::MAX); + let seeder_a = a + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + let seeder_b = b + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); - if !sort_ascending { - releases.sort_by(|release_a, release_b| cmp_fn(release_a, release_b).reverse()); - } else { - releases.sort_by(cmp_fn); - } + seeder_a.cmp(&seeder_b) + }), + }, + SortOption { + name: "Language", + cmp_fn: Some(|a, b| { + let default_language_vec = vec![Language { + name: "_".to_owned(), + }]; + let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0]; + let language_b = &b.languages.as_ref().unwrap_or(&default_language_vec)[0]; - releases + language_a.cmp(language_b) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| a.quality.cmp(&b.quality)), + }, + ] } diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index b1e701b..390268b 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -1,7 +1,8 @@ #[cfg(test)] mod tests { + use std::cmp::Ordering; + use pretty_assertions::assert_str_eq; - use rstest::rstest; use serde_json::Number; use strum::IntoEnumIterator; @@ -9,21 +10,20 @@ mod tests { use crate::app::App; use crate::event::Key; use crate::handlers::radarr_handlers::library::movie_details_handler::{ - sort_releases_by_selected_field, MovieDetailsHandler, + releases_sorting_options, MovieDetailsHandler, }; use crate::handlers::KeyEventHandler; use crate::models::radarr_models::{ - Credit, Language, MovieHistoryItem, Quality, QualityWrapper, Release, ReleaseField, + Credit, Language, MovieHistoryItem, Quality, QualityWrapper, Release, }; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; + use crate::models::stateful_table::SortOption; use crate::models::{HorizontallyScrollableText, ScrollableText}; mod test_handle_scroll_up_and_down { - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; - use strum::IntoEnumIterator; - use crate::models::radarr_models::ReleaseField; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::simple_stateful_iterable_vec; @@ -261,12 +261,10 @@ mod tests { fn test_manual_search_sort_scroll( #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, ) { - let release_field_vec = Vec::from_iter(ReleaseField::iter()); + let release_field_vec = sort_options(); let mut app = App::default(); let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_releases_sort - .set_items(release_field_vec.clone()); + movie_details_modal.movie_releases.sorting(sort_options()); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); if key == Key::Up { @@ -286,7 +284,10 @@ mod tests { .movie_details_modal .as_ref() .unwrap() - .movie_releases_sort + .movie_releases + .sort + .as_ref() + .unwrap() .current_selection(), &release_field_vec[i] ); @@ -308,7 +309,10 @@ mod tests { .movie_details_modal .as_ref() .unwrap() - .movie_releases_sort + .movie_releases + .sort + .as_ref() + .unwrap() .current_selection(), &release_field_vec[(i + 1) % release_field_vec.len()] ); @@ -318,10 +322,7 @@ mod tests { } mod test_handle_home_end { - use strum::IntoEnumIterator; - use crate::extended_stateful_iterable_vec; - use crate::models::radarr_models::ReleaseField; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use super::*; @@ -597,12 +598,10 @@ mod tests { #[test] fn test_manual_search_sort_home_end() { - let release_field_vec = Vec::from_iter(ReleaseField::iter()); + let release_field_vec = sort_options(); let mut app = App::default(); let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_releases_sort - .set_items(release_field_vec.clone()); + movie_details_modal.movie_releases.sorting(sort_options()); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( @@ -620,7 +619,10 @@ mod tests { .movie_details_modal .as_ref() .unwrap() - .movie_releases_sort + .movie_releases + .sort + .as_ref() + .unwrap() .current_selection(), &release_field_vec[release_field_vec.len() - 1] ); @@ -640,7 +642,10 @@ mod tests { .movie_details_modal .as_ref() .unwrap() - .movie_releases_sort + .movie_releases + .sort + .as_ref() + .unwrap() .current_selection(), &release_field_vec[0] ); @@ -720,7 +725,6 @@ mod tests { use pretty_assertions::assert_eq; use rstest::rstest; - use crate::models::radarr_models::ReleaseField; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::network::radarr_network::RadarrEvent; @@ -805,13 +809,9 @@ mod tests { #[test] fn test_manual_search_sort_prompt_submit() { let mut app = App::default(); - let mut movie_details_modal = MovieDetailsModal { - sort_ascending: Some(true), - ..MovieDetailsModal::default() - }; - movie_details_modal - .movie_releases_sort - .set_items(vec![ReleaseField::default()]); + let mut movie_details_modal = MovieDetailsModal::default(); + movie_details_modal.movie_releases.sort_asc = true; + movie_details_modal.movie_releases.sorting(sort_options()); movie_details_modal.movie_releases.set_items(release_vec()); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); @@ -908,13 +908,13 @@ mod tests { use rstest::rstest; use strum::IntoEnumIterator; + use crate::handlers::radarr_handlers::library::movie_details_handler::releases_sorting_options; use crate::models::radarr_models::{MinimumAvailability, Movie}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; use crate::models::servarr_data::radarr::radarr_data::{ RadarrData, EDIT_MOVIE_SELECTION_BLOCKS, }; - use crate::test_edit_movie_key; use super::*; @@ -964,23 +964,6 @@ mod tests { app.get_current_route(), &ActiveRadarrBlock::ManualSearchSortPrompt.into() ); - assert!(!app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases_sort - .items - .is_empty()); - assert!(app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .sort_ascending - .is_some()); assert_eq!( app .data @@ -988,8 +971,22 @@ mod tests { .movie_details_modal .as_ref() .unwrap() - .sort_ascending, - Some(false) + .movie_releases + .sort + .as_ref() + .unwrap() + .items, + releases_sorting_options() + ); + assert!( + !app + .data + .radarr_data + .movie_details_modal + .as_ref() + .unwrap() + .movie_releases + .sort_asc ); } @@ -1067,46 +1064,155 @@ mod tests { } } - #[rstest] - fn test_sort_releases_by_selected_field( - #[values( - ReleaseField::Source, - ReleaseField::Age, - ReleaseField::Title, - ReleaseField::Indexer, - ReleaseField::Size, - ReleaseField::Peers, - ReleaseField::Language, - ReleaseField::Quality - )] - field: ReleaseField, - ) { - let mut expected_vec = release_vec(); + #[test] + fn test_releases_sorting_options_source() { + let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.protocol.cmp(&b.protocol); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); - let sorted_releases = sort_releases_by_selected_field(release_vec(), field, true); + let sort_option = releases_sorting_options()[0].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); - assert_eq!(sorted_releases, expected_vec); - - let sorted_releases = sort_releases_by_selected_field(release_vec(), field, false); - - expected_vec.reverse(); - assert_eq!(sorted_releases, expected_vec); + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Source"); } #[test] - fn test_sort_releases_by_selected_field_rejected() { - let mut expected_vec = Vec::from(&release_vec()[1..]); - expected_vec.push(release_vec()[0].clone()); + fn test_releases_sorting_options_age() { + let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.age.cmp(&b.age); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); - let sorted_releases = - sort_releases_by_selected_field(release_vec(), ReleaseField::Rejected, true); + let sort_option = releases_sorting_options()[1].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); - assert_eq!(sorted_releases, expected_vec); + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Age"); + } - let sorted_releases = - sort_releases_by_selected_field(release_vec(), ReleaseField::Rejected, false); + #[test] + fn test_releases_sorting_options_rejected() { + let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.rejected.cmp(&b.rejected); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); - assert_eq!(sorted_releases, release_vec()); + let sort_option = releases_sorting_options()[2].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Rejected"); + } + + #[test] + fn test_releases_sorting_options_title() { + let expected_cmp_fn: fn(&Release, &Release) -> Ordering = + |a, b| a.title.text.cmp(&b.title.text); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[3].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Title"); + } + + #[test] + fn test_releases_sorting_options_indexer() { + let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.indexer.cmp(&b.indexer); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[4].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Indexer"); + } + + #[test] + fn test_releases_sorting_options_size() { + let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.size.cmp(&b.size); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[5].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Size"); + } + + #[test] + fn test_releases_sorting_options_peers() { + let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| { + let default_number = Number::from(i64::MAX); + let seeder_a = a + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + let seeder_b = b + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + + seeder_a.cmp(&seeder_b) + }; + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[6].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Peers"); + } + + #[test] + fn test_releases_sorting_options_language() { + let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| { + let default_language_vec = vec![Language { + name: "_".to_owned(), + }]; + let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0]; + let language_b = &b.languages.as_ref().unwrap_or(&default_language_vec)[0]; + + language_a.cmp(language_b) + }; + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[7].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Language"); + } + + #[test] + fn test_releases_sorting_options_quality() { + let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.quality.cmp(&b.quality); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[8].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Quality"); } fn release_vec() -> Vec { @@ -1166,6 +1272,13 @@ mod tests { vec![release_a, release_b, release_c] } + fn sort_options() -> Vec> { + vec![SortOption { + name: "Test 1", + cmp_fn: Some(|a, b| a.age.cmp(&b.age)), + }] + } + #[test] fn test_movie_details_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { diff --git a/src/handlers/radarr_handlers/system/system_details_handler.rs b/src/handlers/radarr_handlers/system/system_details_handler.rs index 47e9cdf..0205b43 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler.rs @@ -3,7 +3,8 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS}; -use crate::models::{Scrollable, StatefulList}; +use crate::models::stateful_list::StatefulList; +use crate::models::Scrollable; use crate::network::radarr_network::RadarrEvent; #[cfg(test)] diff --git a/src/models/mod.rs b/src/models/mod.rs index 811554d..6598a53 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -2,267 +2,14 @@ use std::cell::RefCell; use std::fmt::{Debug, Display, Formatter}; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; -use ratatui::widgets::{ListState, TableState}; use regex::Regex; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Number; pub mod radarr_models; pub mod servarr_data; - -macro_rules! stateful_iterable { - ($name:ident, $state:ty) => { - #[derive(Default)] - pub struct $name { - pub state: $state, - pub items: Vec, - pub filter: Option, - pub search: Option, - pub filtered_items: Option>, - pub filtered_state: Option<$state>, - } - - impl Scrollable for $name { - fn scroll_down(&mut self) { - if let Some(filtered_items) = self.filtered_items.as_ref() { - if filtered_items.is_empty() { - return; - } - - let selected_row = match self.filtered_state.as_ref().unwrap().selected() { - Some(i) => { - if i >= filtered_items.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - - self - .filtered_state - .as_mut() - .unwrap() - .select(Some(selected_row)); - return; - } - - if self.items.is_empty() { - return; - } - - let selected_row = match self.state.selected() { - Some(i) => { - if i >= self.items.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - - self.state.select(Some(selected_row)); - } - - fn scroll_up(&mut self) { - if let Some(filtered_items) = self.filtered_items.as_ref() { - if filtered_items.is_empty() { - return; - } - - let selected_row = match self.filtered_state.as_ref().unwrap().selected() { - Some(i) => { - if i == 0 { - filtered_items.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - - self - .filtered_state - .as_mut() - .unwrap() - .select(Some(selected_row)); - return; - } - - if self.items.is_empty() { - return; - } - - let selected_row = match self.state.selected() { - Some(i) => { - if i == 0 { - self.items.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - - self.state.select(Some(selected_row)); - } - - fn scroll_to_top(&mut self) { - if let Some(filtered_items) = self.filtered_items.as_ref() { - if filtered_items.is_empty() { - return; - } - - self.filtered_state.as_mut().unwrap().select(Some(0)); - return; - } - - if self.items.is_empty() { - return; - } - - self.state.select(Some(0)); - } - - fn scroll_to_bottom(&mut self) { - if let Some(filtered_items) = self.filtered_items.as_ref() { - if filtered_items.is_empty() { - return; - } - - self - .filtered_state - .as_mut() - .unwrap() - .select(Some(filtered_items.len() - 1)); - return; - } - - if self.items.is_empty() { - return; - } - - self.state.select(Some(self.items.len() - 1)); - } - } - - #[allow(dead_code)] - impl $name - where - T: Clone + PartialEq + Eq + Debug, - { - pub fn set_items(&mut self, items: Vec) { - let items_len = items.len(); - self.items = items; - if !self.items.is_empty() { - let selected_row = self.state.selected().map_or(0, |i| { - if i > 0 && i < items_len { - i - } else if i >= items_len { - items_len - 1 - } else { - 0 - } - }); - self.state.select(Some(selected_row)); - } - } - - pub fn set_filtered_items(&mut self, filtered_items: Vec) { - self.filtered_items = Some(filtered_items); - let mut filtered_state: $state = Default::default(); - filtered_state.select(Some(0)); - self.filtered_state = Some(filtered_state); - } - - pub fn select_index(&mut self, index: Option) { - if let Some(filtered_state) = &mut self.filtered_state { - filtered_state.select(index); - } else { - self.state.select(index); - } - } - - pub fn current_selection(&self) -> &T { - if let Some(filtered_items) = &self.filtered_items { - &filtered_items[self - .filtered_state - .as_ref() - .unwrap() - .selected() - .unwrap_or(0)] - } else { - &self.items[self.state.selected().unwrap_or(0)] - } - } - - pub fn apply_filter(&mut self, filter_field: fn(&T) -> &str) -> bool { - let filter_matches = match self.filter { - Some(ref filter) if !filter.text.is_empty() => { - let scrubbed_filter = strip_non_search_characters(&filter.text.clone()); - - self - .items - .iter() - .filter(|item| { - strip_non_search_characters(filter_field(&item)).contains(&scrubbed_filter) - }) - .cloned() - .collect() - } - _ => Vec::new(), - }; - - self.filter = None; - - if filter_matches.is_empty() { - return false; - } - - self.set_filtered_items(filter_matches); - return true; - } - - pub fn reset_filter(&mut self) { - self.filter = None; - self.filtered_items = None; - self.filtered_state = None; - } - - pub fn apply_search(&mut self, search_field: fn(&T) -> &str) -> bool { - let search_index = if let Some(search) = self.search.as_ref() { - let search_string = search.text.clone().to_lowercase(); - - self - .filtered_items - .as_ref() - .unwrap_or(&self.items) - .iter() - .position(|item| { - strip_non_search_characters(search_field(&item)).contains(&search_string) - }) - } else { - None - }; - - self.search = None; - - if search_index.is_none() { - return false; - } - - self.select_index(search_index); - return true; - } - - pub fn reset_search(&mut self) { - self.search = None; - } - } - }; -} +pub mod stateful_list; +pub mod stateful_table; #[cfg(test)] #[path = "model_tests.rs"] @@ -289,9 +36,6 @@ pub trait Scrollable { fn scroll_to_bottom(&mut self); } -stateful_iterable!(StatefulList, ListState); -stateful_iterable!(StatefulTable, TableState); - #[derive(Default)] pub struct ScrollableText { pub items: Vec, diff --git a/src/models/model_tests.rs b/src/models/model_tests.rs index dcaaf2d..1d9fcf3 100644 --- a/src/models/model_tests.rs +++ b/src/models/model_tests.rs @@ -3,7 +3,6 @@ mod tests { use std::cell::RefCell; use pretty_assertions::{assert_eq, assert_str_eq}; - use ratatui::widgets::{ListState, TableState}; use serde::de::value::Error as ValueError; use serde::de::value::F64Deserializer; use serde::de::value::I64Deserializer; @@ -13,8 +12,7 @@ mod tests { use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::{from_i64, strip_non_search_characters}; use crate::models::{ - BlockSelectionState, HorizontallyScrollableText, Scrollable, ScrollableText, StatefulList, - StatefulTable, TabRoute, TabState, + BlockSelectionState, HorizontallyScrollableText, Scrollable, ScrollableText, TabRoute, TabState, }; const BLOCKS: [ActiveRadarrBlock; 6] = [ @@ -26,940 +24,6 @@ mod tests { ActiveRadarrBlock::AddMovieConfirmPrompt, ]; - #[test] - fn test_stateful_table_scrolling_on_empty_table_performs_no_op() { - let mut stateful_table: StatefulTable = StatefulTable::default(); - - assert_eq!(stateful_table.state.selected(), None); - - stateful_table.scroll_up(); - - assert_eq!(stateful_table.state.selected(), None); - - stateful_table.scroll_down(); - - assert_eq!(stateful_table.state.selected(), None); - - stateful_table.scroll_to_top(); - - assert_eq!(stateful_table.state.selected(), None); - - stateful_table.scroll_to_bottom(); - } - - #[test] - fn test_stateful_table_filtered_scrolling_on_empty_table_performs_no_op() { - let mut filtered_stateful_table: StatefulTable = StatefulTable { - filtered_items: Some(Vec::new()), - filtered_state: Some(TableState::default()), - ..StatefulTable::default() - }; - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - None - ); - - filtered_stateful_table.scroll_up(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - None - ); - - filtered_stateful_table.scroll_down(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - None - ); - - filtered_stateful_table.scroll_to_top(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - None - ); - - filtered_stateful_table.scroll_to_bottom(); - } - - #[test] - fn test_stateful_table_scroll() { - let mut stateful_table = create_test_stateful_table(); - - assert_eq!(stateful_table.state.selected(), Some(0)); - - stateful_table.scroll_down(); - - assert_eq!(stateful_table.state.selected(), Some(1)); - - stateful_table.scroll_down(); - - assert_eq!(stateful_table.state.selected(), Some(0)); - - stateful_table.scroll_up(); - - assert_eq!(stateful_table.state.selected(), Some(1)); - - stateful_table.scroll_up(); - - assert_eq!(stateful_table.state.selected(), Some(0)); - - stateful_table.scroll_to_bottom(); - - assert_eq!(stateful_table.state.selected(), Some(1)); - - stateful_table.scroll_to_top(); - - assert_eq!(stateful_table.state.selected(), Some(0)); - } - - #[test] - fn test_stateful_table_filtered_items_scroll() { - let mut filtered_stateful_table = create_test_filtered_stateful_table(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - - filtered_stateful_table.scroll_down(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(1) - ); - - filtered_stateful_table.scroll_down(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - - filtered_stateful_table.scroll_up(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(1) - ); - - filtered_stateful_table.scroll_up(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - - filtered_stateful_table.scroll_to_bottom(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(1) - ); - - filtered_stateful_table.scroll_to_top(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - } - - #[test] - fn test_stateful_table_set_items() { - let items_vec = vec!["Test 1", "Test 2", "Test 3"]; - let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); - - stateful_table.set_items(items_vec.clone()); - - assert_eq!(stateful_table.state.selected(), Some(0)); - - stateful_table.state.select(Some(1)); - stateful_table.set_items(items_vec.clone()); - - assert_eq!(stateful_table.state.selected(), Some(1)); - - stateful_table.state.select(Some(3)); - stateful_table.set_items(items_vec); - - assert_eq!(stateful_table.state.selected(), Some(2)); - } - - #[test] - fn test_stateful_table_set_filtered_items() { - let filtered_items_vec = vec!["Test 1", "Test 2", "Test 3"]; - let mut filtered_stateful_table: StatefulTable<&str> = StatefulTable::default(); - - filtered_stateful_table.set_filtered_items(filtered_items_vec.clone()); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - assert_eq!( - filtered_stateful_table.filtered_items, - Some(filtered_items_vec.clone()) - ); - } - - #[test] - fn test_stateful_table_current_selection() { - let mut stateful_table = create_test_stateful_table(); - - assert_str_eq!(stateful_table.current_selection(), &stateful_table.items[0]); - - stateful_table.state.select(Some(1)); - - assert_str_eq!(stateful_table.current_selection(), &stateful_table.items[1]); - } - - #[test] - fn test_filtered_stateful_table_current_selection() { - let mut filtered_stateful_table = create_test_filtered_stateful_table(); - - assert_str_eq!( - filtered_stateful_table.current_selection(), - &filtered_stateful_table.filtered_items.as_ref().unwrap()[0] - ); - - filtered_stateful_table - .filtered_state - .as_mut() - .unwrap() - .select(Some(1)); - - assert_str_eq!( - filtered_stateful_table.current_selection(), - &filtered_stateful_table.filtered_items.as_ref().unwrap()[1] - ); - } - - #[test] - fn test_stateful_table_select_index() { - let mut stateful_table = create_test_stateful_table(); - - assert_eq!(stateful_table.state.selected(), Some(0)); - - stateful_table.select_index(Some(1)); - - assert_eq!(stateful_table.state.selected(), Some(1)); - - stateful_table.select_index(None); - - assert_eq!(stateful_table.state.selected(), None); - } - - #[test] - fn test_filtered_stateful_table_select_index() { - let mut filtered_stateful_table = create_test_filtered_stateful_table(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - - filtered_stateful_table.select_index(Some(1)); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(1) - ); - - filtered_stateful_table.select_index(None); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - None - ); - } - - #[test] - fn test_stateful_table_scroll_up() { - let mut stateful_table = create_test_stateful_table(); - - assert_eq!(stateful_table.state.selected(), Some(0)); - - stateful_table.scroll_up(); - - assert_eq!(stateful_table.state.selected(), Some(1)); - - stateful_table.scroll_up(); - - assert_eq!(stateful_table.state.selected(), Some(0)); - } - - #[test] - fn test_filtered_stateful_table_scroll_up() { - let mut filtered_stateful_table = create_test_filtered_stateful_table(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - - filtered_stateful_table.scroll_up(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(1) - ); - - filtered_stateful_table.scroll_up(); - - assert_eq!( - filtered_stateful_table - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - } - - #[test] - fn test_stateful_table_apply_filter() { - let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); - stateful_table.set_items(vec!["this", "is", "a", "test"]); - stateful_table.filter = Some("i".into()); - let expected_items = vec!["this", "is"]; - let mut expected_state = TableState::default(); - expected_state.select(Some(0)); - - let has_matches = stateful_table.apply_filter(|&item| item); - - assert_eq!(stateful_table.filter, None); - assert_eq!(stateful_table.filtered_items, Some(expected_items)); - assert_eq!(stateful_table.filtered_state, Some(expected_state)); - assert!(has_matches); - } - - #[test] - fn test_stateful_table_apply_filter_no_matches() { - let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); - stateful_table.set_items(vec!["this", "is", "a", "test"]); - stateful_table.filter = Some("z".into()); - - let has_matches = stateful_table.apply_filter(|&item| item); - - assert_eq!(stateful_table.filter, None); - assert_eq!(stateful_table.filtered_items, None); - assert_eq!(stateful_table.filtered_state, None); - assert!(!has_matches); - } - - #[test] - fn test_stateful_table_reset_filter() { - let mut stateful_table = create_test_filtered_stateful_table(); - stateful_table.reset_filter(); - - assert_eq!(stateful_table.filter, None); - assert_eq!(stateful_table.filtered_items, None); - assert_eq!(stateful_table.filtered_state, None); - } - - #[test] - fn test_stateful_table_apply_search() { - let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); - stateful_table.set_items(vec!["this", "is", "a", "test"]); - stateful_table.search = Some("test".into()); - let mut expected_state = TableState::default(); - expected_state.select(Some(3)); - - let has_match = stateful_table.apply_search(|&item| item); - - assert_eq!(stateful_table.search, None); - assert_eq!(stateful_table.state, expected_state); - assert!(has_match); - } - - #[test] - fn test_stateful_table_apply_search_no_match() { - let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); - stateful_table.set_items(vec!["this", "is", "a", "test"]); - stateful_table.search = Some("shi-mon-a!".into()); - - let has_match = stateful_table.apply_search(|&item| item); - - assert_eq!(stateful_table.search, None); - assert!(!has_match); - } - - #[test] - fn test_filtered_stateful_table_apply_search() { - let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); - stateful_table.set_filtered_items(vec!["this", "is", "a", "test"]); - stateful_table.search = Some("test".into()); - let mut expected_state = TableState::default(); - expected_state.select(Some(3)); - - let has_match = stateful_table.apply_search(|&item| item); - - assert_eq!(stateful_table.search, None); - assert_eq!(stateful_table.filtered_state, Some(expected_state)); - assert!(has_match); - } - - #[test] - fn test_filtered_stateful_table_apply_search_no_match() { - let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); - stateful_table.set_filtered_items(vec!["this", "is", "a", "test"]); - stateful_table.search = Some("shi-mon-a!".into()); - let mut expected_state = TableState::default(); - expected_state.select(Some(0)); - - let has_match = stateful_table.apply_search(|&item| item); - - assert_eq!(stateful_table.search, None); - assert_eq!(stateful_table.filtered_state, Some(expected_state)); - assert!(!has_match); - } - - #[test] - fn test_stateful_table_reset_search() { - let mut stateful_table = create_test_stateful_table(); - stateful_table.search = Some("test".into()); - stateful_table.reset_search(); - - assert_eq!(stateful_table.search, None); - } - - #[test] - fn test_stateful_list_scrolling_on_empty_list_performs_no_op() { - let mut stateful_list: StatefulList = StatefulList::default(); - - assert_eq!(stateful_list.state.selected(), None); - - stateful_list.scroll_up(); - - assert_eq!(stateful_list.state.selected(), None); - - stateful_list.scroll_down(); - - assert_eq!(stateful_list.state.selected(), None); - - stateful_list.scroll_to_top(); - - assert_eq!(stateful_list.state.selected(), None); - - stateful_list.scroll_to_bottom(); - } - - #[test] - fn test_filtered_stateful_list_scrolling_on_empty_list_performs_no_op() { - let mut filtered_stateful_list: StatefulList = StatefulList { - filtered_items: Some(Vec::new()), - filtered_state: Some(ListState::default()), - ..StatefulList::default() - }; - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - None - ); - - filtered_stateful_list.scroll_up(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - None - ); - - filtered_stateful_list.scroll_down(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - None - ); - - filtered_stateful_list.scroll_to_top(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - None - ); - - filtered_stateful_list.scroll_to_bottom(); - } - - #[test] - fn test_stateful_list_scroll() { - let mut stateful_list = create_test_stateful_list(); - - assert_eq!(stateful_list.state.selected(), Some(0)); - - stateful_list.scroll_down(); - - assert_eq!(stateful_list.state.selected(), Some(1)); - - stateful_list.scroll_down(); - - assert_eq!(stateful_list.state.selected(), Some(0)); - - stateful_list.scroll_up(); - - assert_eq!(stateful_list.state.selected(), Some(1)); - - stateful_list.scroll_up(); - - assert_eq!(stateful_list.state.selected(), Some(0)); - - stateful_list.scroll_to_bottom(); - - assert_eq!(stateful_list.state.selected(), Some(1)); - - stateful_list.scroll_to_top(); - - assert_eq!(stateful_list.state.selected(), Some(0)); - } - - #[test] - fn test_filtered_stateful_list_scroll() { - let mut filtered_stateful_list = create_test_filtered_stateful_list(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - - filtered_stateful_list.scroll_down(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(1) - ); - - filtered_stateful_list.scroll_down(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - - filtered_stateful_list.scroll_up(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(1) - ); - - filtered_stateful_list.scroll_up(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - - filtered_stateful_list.scroll_to_bottom(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(1) - ); - - filtered_stateful_list.scroll_to_top(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - } - - #[test] - fn test_stateful_list_set_items() { - let items_vec = vec!["Test 1", "Test 2", "Test 3"]; - let mut stateful_list: StatefulList<&str> = StatefulList::default(); - - stateful_list.set_items(items_vec.clone()); - - assert_eq!(stateful_list.state.selected(), Some(0)); - - stateful_list.state.select(Some(1)); - stateful_list.set_items(items_vec.clone()); - - assert_eq!(stateful_list.state.selected(), Some(1)); - - stateful_list.state.select(Some(3)); - stateful_list.set_items(items_vec); - - assert_eq!(stateful_list.state.selected(), Some(2)); - } - - #[test] - fn test_stateful_list_set_filtered_items() { - let filtered_items_vec = vec!["Test 1", "Test 2", "Test 3"]; - let mut filtered_stateful_list: StatefulList<&str> = StatefulList::default(); - - filtered_stateful_list.set_filtered_items(filtered_items_vec.clone()); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - assert_eq!( - filtered_stateful_list.filtered_items, - Some(filtered_items_vec.clone()) - ); - } - - #[test] - fn test_stateful_list_current_selection() { - let mut stateful_list = create_test_stateful_list(); - - assert_str_eq!(stateful_list.current_selection(), &stateful_list.items[0]); - - stateful_list.state.select(Some(1)); - - assert_str_eq!(stateful_list.current_selection(), &stateful_list.items[1]); - } - - #[test] - fn test_filtered_stateful_list_current_selection() { - let mut filtered_stateful_list = create_test_filtered_stateful_list(); - - assert_str_eq!( - filtered_stateful_list.current_selection(), - &filtered_stateful_list.filtered_items.as_ref().unwrap()[0] - ); - - filtered_stateful_list - .filtered_state - .as_mut() - .unwrap() - .select(Some(1)); - - assert_str_eq!( - filtered_stateful_list.current_selection(), - &filtered_stateful_list.filtered_items.as_ref().unwrap()[1] - ); - } - - #[test] - fn test_stateful_list_select_index() { - let mut stateful_list = create_test_stateful_list(); - - assert_eq!(stateful_list.state.selected(), Some(0)); - - stateful_list.select_index(Some(1)); - - assert_eq!(stateful_list.state.selected(), Some(1)); - - stateful_list.select_index(None); - - assert_eq!(stateful_list.state.selected(), None); - } - - #[test] - fn test_filtered_stateful_list_select_index() { - let mut filtered_stateful_list = create_test_filtered_stateful_list(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - - filtered_stateful_list.select_index(Some(1)); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(1) - ); - - filtered_stateful_list.select_index(None); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - None - ); - } - - #[test] - fn test_stateful_list_scroll_up() { - let mut stateful_list = create_test_stateful_list(); - - assert_eq!(stateful_list.state.selected(), Some(0)); - - stateful_list.scroll_up(); - - assert_eq!(stateful_list.state.selected(), Some(1)); - - stateful_list.scroll_up(); - - assert_eq!(stateful_list.state.selected(), Some(0)); - } - - #[test] - fn test_filtered_stateful_list_scroll_up() { - let mut filtered_stateful_list = create_test_filtered_stateful_list(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - - filtered_stateful_list.scroll_up(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(1) - ); - - filtered_stateful_list.scroll_up(); - - assert_eq!( - filtered_stateful_list - .filtered_state - .as_ref() - .unwrap() - .selected(), - Some(0) - ); - } - - #[test] - fn test_stateful_list_apply_filter() { - let mut stateful_list: StatefulList<&str> = StatefulList::default(); - stateful_list.set_items(vec!["this", "is", "a", "test"]); - stateful_list.filter = Some("i".into()); - let expected_items = vec!["this", "is"]; - let mut expected_state = ListState::default(); - expected_state.select(Some(0)); - - let has_matches = stateful_list.apply_filter(|&item| item); - - assert_eq!(stateful_list.filter, None); - assert_eq!(stateful_list.filtered_items, Some(expected_items)); - assert_eq!(stateful_list.filtered_state, Some(expected_state)); - assert!(has_matches); - } - - #[test] - fn test_stateful_list_apply_filter_no_matches() { - let mut stateful_list: StatefulList<&str> = StatefulList::default(); - stateful_list.set_items(vec!["this", "is", "a", "test"]); - stateful_list.filter = Some("z".into()); - - let has_matches = stateful_list.apply_filter(|&item| item); - - assert_eq!(stateful_list.filter, None); - assert_eq!(stateful_list.filtered_items, None); - assert_eq!(stateful_list.filtered_state, None); - assert!(!has_matches); - } - - #[test] - fn test_stateful_list_reset_filter() { - let mut stateful_list = create_test_filtered_stateful_list(); - stateful_list.reset_filter(); - - assert_eq!(stateful_list.filter, None); - assert_eq!(stateful_list.filtered_items, None); - assert_eq!(stateful_list.filtered_state, None); - } - - #[test] - fn test_stateful_list_apply_search() { - let mut stateful_list: StatefulList<&str> = StatefulList::default(); - stateful_list.set_items(vec!["this", "is", "a", "test"]); - stateful_list.search = Some("test".into()); - let mut expected_state = ListState::default(); - expected_state.select(Some(3)); - - let has_match = stateful_list.apply_search(|&item| item); - - assert_eq!(stateful_list.search, None); - assert_eq!(stateful_list.state, expected_state); - assert!(has_match); - } - - #[test] - fn test_stateful_list_apply_search_no_match() { - let mut stateful_list: StatefulList<&str> = StatefulList::default(); - stateful_list.set_items(vec!["this", "is", "a", "test"]); - stateful_list.search = Some("shi-mon-a!".into()); - - let has_match = stateful_list.apply_search(|&item| item); - - assert_eq!(stateful_list.search, None); - assert!(!has_match); - } - - #[test] - fn test_filtered_stateful_list_apply_search() { - let mut stateful_list: StatefulList<&str> = StatefulList::default(); - stateful_list.set_filtered_items(vec!["this", "is", "a", "test"]); - stateful_list.search = Some("test".into()); - let mut expected_state = ListState::default(); - expected_state.select(Some(3)); - - let has_match = stateful_list.apply_search(|&item| item); - - assert_eq!(stateful_list.search, None); - assert_eq!(stateful_list.filtered_state, Some(expected_state)); - assert!(has_match); - } - - #[test] - fn test_filtered_stateful_list_apply_search_no_match() { - let mut stateful_list: StatefulList<&str> = StatefulList::default(); - stateful_list.set_filtered_items(vec!["this", "is", "a", "test"]); - stateful_list.search = Some("shi-mon-a!".into()); - let mut expected_state = ListState::default(); - expected_state.select(Some(0)); - - let has_match = stateful_list.apply_search(|&item| item); - - assert_eq!(stateful_list.search, None); - assert_eq!(stateful_list.filtered_state, Some(expected_state)); - assert!(!has_match); - } - - #[test] - fn test_stateful_list_reset_search() { - let mut stateful_list = create_test_stateful_list(); - stateful_list.search = Some("test".into()); - stateful_list.reset_search(); - - assert_eq!(stateful_list.search, None); - } - #[test] fn test_scrollable_text_with_string() { let scrollable_text = ScrollableText::with_string("Test \n String \n".to_owned()); @@ -1488,34 +552,6 @@ mod tests { ] } - fn create_test_stateful_table() -> StatefulTable<&'static str> { - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(vec!["Test 1", "Test 2"]); - - stateful_table - } - - fn create_test_filtered_stateful_table() -> StatefulTable<&'static str> { - let mut stateful_table = StatefulTable::default(); - stateful_table.set_filtered_items(vec!["Test 1", "Test 2"]); - - stateful_table - } - - fn create_test_stateful_list() -> StatefulList<&'static str> { - let mut stateful_list = StatefulList::default(); - stateful_list.set_items(vec!["Test 1", "Test 2"]); - - stateful_list - } - - fn create_test_filtered_stateful_list() -> StatefulList<&'static str> { - let mut stateful_list = StatefulList::default(); - stateful_list.set_filtered_items(vec!["Test 1", "Test 2"]); - - stateful_list - } - #[test] fn test_strip_non_alphanumeric_characters() { assert_eq!( diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 1465494..ec7133c 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; use derivative::Derivative; use serde::{Deserialize, Serialize}; use serde_json::{Number, Value}; -use strum_macros::{Display, EnumIter}; +use strum_macros::EnumIter; use crate::models::HorizontallyScrollableText; @@ -454,20 +454,6 @@ pub struct ReleaseDownloadBody { pub movie_id: i64, } -#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter, Display)] -pub enum ReleaseField { - #[default] - Source, - Age, - Rejected, - Title, - Indexer, - Size, - Peers, - Language, - Quality, -} - #[derive(Default, Deserialize, Debug, Clone, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RootFolder { diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index fb44826..074991c 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -1,10 +1,13 @@ +use strum::IntoEnumIterator; + use crate::models::radarr_models::{ Collection, Credit, Indexer, MinimumAvailability, Monitor, Movie, MovieHistoryItem, Release, - ReleaseField, RootFolder, + RootFolder, }; use crate::models::servarr_data::radarr::radarr_data::RadarrData; -use crate::models::{HorizontallyScrollableText, ScrollableText, StatefulList, StatefulTable}; -use strum::IntoEnumIterator; +use crate::models::stateful_list::StatefulList; +use crate::models::stateful_table::StatefulTable; +use crate::models::{HorizontallyScrollableText, ScrollableText}; #[cfg(test)] #[path = "modals_tests.rs"] @@ -20,8 +23,6 @@ pub struct MovieDetailsModal { pub movie_cast: StatefulTable, pub movie_crew: StatefulTable, pub movie_releases: StatefulTable, - pub movie_releases_sort: StatefulList, - pub sort_ascending: Option, } #[derive(Default, Debug, PartialEq, Eq)] diff --git a/src/models/servarr_data/radarr/modals_tests.rs b/src/models/servarr_data/radarr/modals_tests.rs index 03dc309..63479aa 100644 --- a/src/models/servarr_data/radarr/modals_tests.rs +++ b/src/models/servarr_data/radarr/modals_tests.rs @@ -8,7 +8,7 @@ mod test { }; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; use crate::models::servarr_data::radarr::radarr_data::RadarrData; - use crate::models::StatefulTable; + use crate::models::stateful_table::StatefulTable; use bimap::BiMap; use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index 592d249..1ca7221 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -13,9 +13,10 @@ use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, MovieDetailsModal, }; +use crate::models::stateful_list::StatefulList; +use crate::models::stateful_table::StatefulTable; use crate::models::{ - BlockSelectionState, HorizontallyScrollableText, Route, ScrollableText, StatefulList, - StatefulTable, TabRoute, TabState, + BlockSelectionState, HorizontallyScrollableText, Route, ScrollableText, TabRoute, TabState, }; use crate::network::radarr_network::RadarrEvent; use bimap::BiMap; diff --git a/src/models/servarr_data/radarr/radarr_test_utils.rs b/src/models/servarr_data/radarr/radarr_test_utils.rs index c555308..e20e530 100644 --- a/src/models/servarr_data/radarr/radarr_test_utils.rs +++ b/src/models/servarr_data/radarr/radarr_test_utils.rs @@ -1,11 +1,12 @@ #[cfg(test)] pub mod utils { use crate::models::radarr_models::{ - AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, Release, ReleaseField, + AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, Release, }; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::RadarrData; - use crate::models::{HorizontallyScrollableText, ScrollableText, StatefulTable}; + use crate::models::stateful_table::StatefulTable; + use crate::models::{HorizontallyScrollableText, ScrollableText}; pub fn create_test_radarr_data<'a>() -> RadarrData<'a> { let mut movie_details_modal = MovieDetailsModal { @@ -24,10 +25,6 @@ pub mod utils { movie_details_modal .movie_releases .set_items(vec![Release::default()]); - movie_details_modal - .movie_releases_sort - .set_items(vec![ReleaseField::default()]); - movie_details_modal.sort_ascending = Some(true); let mut radarr_data = RadarrData { delete_movie_files: true, diff --git a/src/models/stateful_list.rs b/src/models/stateful_list.rs new file mode 100644 index 0000000..be3db74 --- /dev/null +++ b/src/models/stateful_list.rs @@ -0,0 +1,95 @@ +use crate::models::Scrollable; +use ratatui::widgets::ListState; +use std::fmt::Debug; + +#[cfg(test)] +#[path = "stateful_list_tests.rs"] +mod stateful_list_tests; + +#[derive(Default)] +pub struct StatefulList { + pub state: ListState, + pub items: Vec, +} + +impl Scrollable for StatefulList { + fn scroll_down(&mut self) { + if self.items.is_empty() { + return; + } + + let selected_row = match self.state.selected() { + Some(i) => { + if i >= self.items.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + + self.state.select(Some(selected_row)); + } + + fn scroll_up(&mut self) { + if self.items.is_empty() { + return; + } + + let selected_row = match self.state.selected() { + Some(i) => { + if i == 0 { + self.items.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + + self.state.select(Some(selected_row)); + } + + fn scroll_to_top(&mut self) { + if self.items.is_empty() { + return; + } + + self.state.select(Some(0)); + } + + fn scroll_to_bottom(&mut self) { + if self.items.is_empty() { + return; + } + + self.state.select(Some(self.items.len() - 1)); + } +} + +impl StatefulList +where + T: Clone + PartialEq + Eq + Debug, +{ + pub fn set_items(&mut self, items: Vec) { + let items_len = items.len(); + self.items = items; + if !self.items.is_empty() { + let selected_row = self.state.selected().map_or(0, |i| { + if i > 0 && i < items_len { + i + } else if i >= items_len { + items_len - 1 + } else { + 0 + } + }); + self.state.select(Some(selected_row)); + } + } + + pub fn current_selection(&self) -> &T { + &self.items[self.state.selected().unwrap_or(0)] + } +} diff --git a/src/models/stateful_list_tests.rs b/src/models/stateful_list_tests.rs new file mode 100644 index 0000000..9f38c39 --- /dev/null +++ b/src/models/stateful_list_tests.rs @@ -0,0 +1,111 @@ +#[cfg(test)] +mod tests { + use crate::models::stateful_list::StatefulList; + use crate::models::Scrollable; + use pretty_assertions::{assert_eq, assert_str_eq}; + + #[test] + fn test_stateful_list_scrolling_on_empty_list_performs_no_op() { + let mut stateful_list: StatefulList = StatefulList::default(); + + assert_eq!(stateful_list.state.selected(), None); + + stateful_list.scroll_up(); + + assert_eq!(stateful_list.state.selected(), None); + + stateful_list.scroll_down(); + + assert_eq!(stateful_list.state.selected(), None); + + stateful_list.scroll_to_top(); + + assert_eq!(stateful_list.state.selected(), None); + + stateful_list.scroll_to_bottom(); + } + + #[test] + fn test_stateful_list_scroll() { + let mut stateful_list = create_test_stateful_list(); + + assert_eq!(stateful_list.state.selected(), Some(0)); + + stateful_list.scroll_down(); + + assert_eq!(stateful_list.state.selected(), Some(1)); + + stateful_list.scroll_down(); + + assert_eq!(stateful_list.state.selected(), Some(0)); + + stateful_list.scroll_up(); + + assert_eq!(stateful_list.state.selected(), Some(1)); + + stateful_list.scroll_up(); + + assert_eq!(stateful_list.state.selected(), Some(0)); + + stateful_list.scroll_to_bottom(); + + assert_eq!(stateful_list.state.selected(), Some(1)); + + stateful_list.scroll_to_top(); + + assert_eq!(stateful_list.state.selected(), Some(0)); + } + + #[test] + fn test_stateful_list_set_items() { + let items_vec = vec!["Test 1", "Test 2", "Test 3"]; + let mut stateful_list: StatefulList<&str> = StatefulList::default(); + + stateful_list.set_items(items_vec.clone()); + + assert_eq!(stateful_list.state.selected(), Some(0)); + + stateful_list.state.select(Some(1)); + stateful_list.set_items(items_vec.clone()); + + assert_eq!(stateful_list.state.selected(), Some(1)); + + stateful_list.state.select(Some(3)); + stateful_list.set_items(items_vec); + + assert_eq!(stateful_list.state.selected(), Some(2)); + } + + #[test] + fn test_stateful_list_current_selection() { + let mut stateful_list = create_test_stateful_list(); + + assert_str_eq!(stateful_list.current_selection(), &stateful_list.items[0]); + + stateful_list.state.select(Some(1)); + + assert_str_eq!(stateful_list.current_selection(), &stateful_list.items[1]); + } + + #[test] + fn test_stateful_list_scroll_up() { + let mut stateful_list = create_test_stateful_list(); + + assert_eq!(stateful_list.state.selected(), Some(0)); + + stateful_list.scroll_up(); + + assert_eq!(stateful_list.state.selected(), Some(1)); + + stateful_list.scroll_up(); + + assert_eq!(stateful_list.state.selected(), Some(0)); + } + + fn create_test_stateful_list() -> StatefulList<&'static str> { + let mut stateful_list = StatefulList::default(); + stateful_list.set_items(vec!["Test 1", "Test 2"]); + + stateful_list + } +} diff --git a/src/models/stateful_table.rs b/src/models/stateful_table.rs new file mode 100644 index 0000000..e4f2ade --- /dev/null +++ b/src/models/stateful_table.rs @@ -0,0 +1,299 @@ +use crate::models::stateful_list::StatefulList; +use crate::models::{strip_non_search_characters, HorizontallyScrollableText, Scrollable}; +use ratatui::widgets::TableState; +use std::cmp::Ordering; +use std::fmt::Debug; + +#[cfg(test)] +#[path = "stateful_table_tests.rs"] +mod stateful_table_tests; + +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct SortOption +where + T: Clone + PartialEq + Eq + Debug, +{ + pub name: &'static str, + pub cmp_fn: Option Ordering>, +} + +#[derive(Default)] +pub struct StatefulTable +where + T: Clone + PartialEq + Eq + Debug, +{ + pub state: TableState, + pub items: Vec, + pub filter: Option, + pub search: Option, + pub filtered_items: Option>, + pub filtered_state: Option, + pub sort_asc: bool, + pub sort: Option>>, +} + +impl Scrollable for StatefulTable +where + T: Clone + PartialEq + Eq + Debug, +{ + fn scroll_down(&mut self) { + if let Some(filtered_items) = self.filtered_items.as_ref() { + if filtered_items.is_empty() { + return; + } + + let selected_row = match self.filtered_state.as_ref().unwrap().selected() { + Some(i) => { + if i >= filtered_items.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + + self + .filtered_state + .as_mut() + .unwrap() + .select(Some(selected_row)); + return; + } + + if self.items.is_empty() { + return; + } + + let selected_row = match self.state.selected() { + Some(i) => { + if i >= self.items.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + + self.state.select(Some(selected_row)); + } + + fn scroll_up(&mut self) { + if let Some(filtered_items) = self.filtered_items.as_ref() { + if filtered_items.is_empty() { + return; + } + + let selected_row = match self.filtered_state.as_ref().unwrap().selected() { + Some(i) => { + if i == 0 { + filtered_items.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + + self + .filtered_state + .as_mut() + .unwrap() + .select(Some(selected_row)); + return; + } + + if self.items.is_empty() { + return; + } + + let selected_row = match self.state.selected() { + Some(i) => { + if i == 0 { + self.items.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + + self.state.select(Some(selected_row)); + } + + fn scroll_to_top(&mut self) { + if let Some(filtered_items) = self.filtered_items.as_ref() { + if filtered_items.is_empty() { + return; + } + + self.filtered_state.as_mut().unwrap().select(Some(0)); + return; + } + + if self.items.is_empty() { + return; + } + + self.state.select(Some(0)); + } + + fn scroll_to_bottom(&mut self) { + if let Some(filtered_items) = self.filtered_items.as_ref() { + if filtered_items.is_empty() { + return; + } + + self + .filtered_state + .as_mut() + .unwrap() + .select(Some(filtered_items.len() - 1)); + return; + } + + if self.items.is_empty() { + return; + } + + self.state.select(Some(self.items.len() - 1)); + } +} + +impl StatefulTable +where + T: Clone + PartialEq + Eq + Debug + Default, +{ + pub fn set_items(&mut self, items: Vec) { + let items_len = items.len(); + self.items = items; + if !self.items.is_empty() { + let selected_row = self.state.selected().map_or(0, |i| { + if i > 0 && i < items_len { + i + } else if i >= items_len { + items_len - 1 + } else { + 0 + } + }); + self.state.select(Some(selected_row)); + } + } + + pub fn set_filtered_items(&mut self, filtered_items: Vec) { + self.filtered_items = Some(filtered_items); + let mut filtered_state: TableState = Default::default(); + filtered_state.select(Some(0)); + self.filtered_state = Some(filtered_state); + } + + pub fn select_index(&mut self, index: Option) { + if let Some(filtered_state) = &mut self.filtered_state { + filtered_state.select(index); + } else { + self.state.select(index); + } + } + + pub fn current_selection(&self) -> &T { + if let Some(filtered_items) = &self.filtered_items { + &filtered_items[self + .filtered_state + .as_ref() + .unwrap() + .selected() + .unwrap_or(0)] + } else { + &self.items[self.state.selected().unwrap_or(0)] + } + } + + pub fn sorting(&mut self, sort_options: Vec>) { + let mut sort_options_list = StatefulList::default(); + sort_options_list.set_items(sort_options); + + self.sort = Some(sort_options_list); + } + + pub fn apply_sorting(&mut self) { + if let Some(sort_options) = &mut self.sort { + self.sort_asc = !self.sort_asc; + let selected_sort_option = sort_options.current_selection(); + let mut items = self.filtered_items.as_ref().unwrap_or(&self.items).clone(); + if let Some(cmp_fn) = selected_sort_option.cmp_fn { + if !self.sort_asc { + items.sort_by(|a, b| cmp_fn(a, b).reverse()); + } else { + items.sort_by(cmp_fn); + } + + if self.filtered_items.is_some() { + self.set_filtered_items(items.clone()); + } else { + self.set_items(items); + } + } + } + } + + pub fn apply_filter(&mut self, filter_field: fn(&T) -> &str) -> bool { + let filter_matches = match self.filter { + Some(ref filter) if !filter.text.is_empty() => { + let scrubbed_filter = strip_non_search_characters(&filter.text.clone()); + + self + .items + .iter() + .filter(|item| strip_non_search_characters(filter_field(item)).contains(&scrubbed_filter)) + .cloned() + .collect() + } + _ => Vec::new(), + }; + + self.filter = None; + + if filter_matches.is_empty() { + return false; + } + + self.set_filtered_items(filter_matches); + true + } + + pub fn reset_filter(&mut self) { + self.filter = None; + self.filtered_items = None; + self.filtered_state = None; + } + + pub fn apply_search(&mut self, search_field: fn(&T) -> &str) -> bool { + let search_index = if let Some(search) = self.search.as_ref() { + let search_string = search.text.clone().to_lowercase(); + + self + .filtered_items + .as_ref() + .unwrap_or(&self.items) + .iter() + .position(|item| strip_non_search_characters(search_field(item)).contains(&search_string)) + } else { + None + }; + + self.search = None; + + if search_index.is_none() { + return false; + } + + self.select_index(search_index); + true + } + + pub fn reset_search(&mut self) { + self.search = None; + } +} diff --git a/src/models/stateful_table_tests.rs b/src/models/stateful_table_tests.rs new file mode 100644 index 0000000..9465632 --- /dev/null +++ b/src/models/stateful_table_tests.rs @@ -0,0 +1,615 @@ +#[cfg(test)] +mod tests { + use crate::models::stateful_table::{SortOption, StatefulTable}; + use crate::models::Scrollable; + use pretty_assertions::{assert_eq, assert_str_eq}; + use ratatui::widgets::TableState; + + #[test] + fn test_stateful_table_scrolling_on_empty_table_performs_no_op() { + let mut stateful_table: StatefulTable = StatefulTable::default(); + + assert_eq!(stateful_table.state.selected(), None); + + stateful_table.scroll_up(); + + assert_eq!(stateful_table.state.selected(), None); + + stateful_table.scroll_down(); + + assert_eq!(stateful_table.state.selected(), None); + + stateful_table.scroll_to_top(); + + assert_eq!(stateful_table.state.selected(), None); + + stateful_table.scroll_to_bottom(); + } + + #[test] + fn test_stateful_table_filtered_scrolling_on_empty_table_performs_no_op() { + let mut filtered_stateful_table: StatefulTable = StatefulTable { + filtered_items: Some(Vec::new()), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + None + ); + + filtered_stateful_table.scroll_up(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + None + ); + + filtered_stateful_table.scroll_down(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + None + ); + + filtered_stateful_table.scroll_to_top(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + None + ); + + filtered_stateful_table.scroll_to_bottom(); + } + + #[test] + fn test_stateful_table_scroll() { + let mut stateful_table = create_test_stateful_table(); + + assert_eq!(stateful_table.state.selected(), Some(0)); + + stateful_table.scroll_down(); + + assert_eq!(stateful_table.state.selected(), Some(1)); + + stateful_table.scroll_down(); + + assert_eq!(stateful_table.state.selected(), Some(0)); + + stateful_table.scroll_up(); + + assert_eq!(stateful_table.state.selected(), Some(1)); + + stateful_table.scroll_up(); + + assert_eq!(stateful_table.state.selected(), Some(0)); + + stateful_table.scroll_to_bottom(); + + assert_eq!(stateful_table.state.selected(), Some(1)); + + stateful_table.scroll_to_top(); + + assert_eq!(stateful_table.state.selected(), Some(0)); + } + + #[test] + fn test_stateful_table_filtered_items_scroll() { + let mut filtered_stateful_table = create_test_filtered_stateful_table(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(0) + ); + + filtered_stateful_table.scroll_down(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(1) + ); + + filtered_stateful_table.scroll_down(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(0) + ); + + filtered_stateful_table.scroll_up(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(1) + ); + + filtered_stateful_table.scroll_up(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(0) + ); + + filtered_stateful_table.scroll_to_bottom(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(1) + ); + + filtered_stateful_table.scroll_to_top(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(0) + ); + } + + #[test] + fn test_stateful_table_set_items() { + let items_vec = vec!["Test 1", "Test 2", "Test 3"]; + let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); + + stateful_table.set_items(items_vec.clone()); + + assert_eq!(stateful_table.state.selected(), Some(0)); + + stateful_table.state.select(Some(1)); + stateful_table.set_items(items_vec.clone()); + + assert_eq!(stateful_table.state.selected(), Some(1)); + + stateful_table.state.select(Some(3)); + stateful_table.set_items(items_vec); + + assert_eq!(stateful_table.state.selected(), Some(2)); + } + + #[test] + fn test_stateful_table_set_filtered_items() { + let filtered_items_vec = vec!["Test 1", "Test 2", "Test 3"]; + let mut filtered_stateful_table: StatefulTable<&str> = StatefulTable::default(); + + filtered_stateful_table.set_filtered_items(filtered_items_vec.clone()); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(0) + ); + assert_eq!( + filtered_stateful_table.filtered_items, + Some(filtered_items_vec.clone()) + ); + } + + #[test] + fn test_stateful_table_current_selection() { + let mut stateful_table = create_test_stateful_table(); + + assert_str_eq!(stateful_table.current_selection(), &stateful_table.items[0]); + + stateful_table.state.select(Some(1)); + + assert_str_eq!(stateful_table.current_selection(), &stateful_table.items[1]); + } + + #[test] + fn test_stateful_table_sorting() { + let sort_options: Vec> = vec![ + SortOption { + name: "Test 1", + cmp_fn: None, + }, + SortOption { + name: "Test 2", + cmp_fn: None, + }, + ]; + let mut stateful_table: StatefulTable = StatefulTable::default(); + stateful_table.sorting(sort_options.clone()); + + assert_eq!( + stateful_table.sort.as_ref().unwrap().items, + sort_options.clone() + ); + assert_eq!( + stateful_table.sort.as_ref().unwrap().current_selection(), + &sort_options[0] + ); + } + + #[test] + fn test_stateful_table_apply_sorting_no_op_no_sort_options() { + let mut stateful_table = create_test_stateful_table(); + let expected_items = stateful_table.items.clone(); + + stateful_table.apply_sorting(); + + assert_eq!(stateful_table.items, expected_items); + assert!(!stateful_table.sort_asc); + } + + #[test] + fn test_stateful_table_apply_sorting_no_op_no_cmp_fn() { + let mut stateful_table = create_test_stateful_table(); + stateful_table.sorting(vec![SortOption { + name: "Test 1", + cmp_fn: None, + }]); + let expected_items = stateful_table.items.clone(); + + stateful_table.apply_sorting(); + + assert_eq!(stateful_table.items, expected_items); + assert!(stateful_table.sort_asc); + } + + #[test] + fn test_filtered_stateful_table_apply_sorting_no_op_no_cmp_fn() { + let mut filtered_stateful_table = create_test_filtered_stateful_table(); + filtered_stateful_table.sorting(vec![SortOption { + name: "Test 1", + cmp_fn: None, + }]); + let expected_items = filtered_stateful_table + .filtered_items + .as_ref() + .unwrap() + .clone(); + + filtered_stateful_table.apply_sorting(); + + assert_eq!( + *filtered_stateful_table.filtered_items.as_ref().unwrap(), + expected_items + ); + assert!(filtered_stateful_table.sort_asc); + } + + #[test] + fn test_stateful_table_apply_sorting() { + let mut stateful_table = create_test_stateful_table(); + stateful_table.sorting(vec![SortOption { + name: "Test 1", + cmp_fn: Some(|a, b| a.cmp(b)), + }]); + let mut expected_items = stateful_table.items.clone(); + expected_items.sort(); + + stateful_table.apply_sorting(); + + assert_eq!(stateful_table.items, expected_items); + assert!(stateful_table.sort_asc); + + stateful_table.apply_sorting(); + + expected_items.reverse(); + assert_eq!(stateful_table.items, expected_items); + assert!(!stateful_table.sort_asc); + } + + #[test] + fn test_filtered_stateful_table_apply_sorting() { + let mut filtered_stateful_table = create_test_filtered_stateful_table(); + filtered_stateful_table.sorting(vec![SortOption { + name: "Test 1", + cmp_fn: Some(|a, b| a.cmp(b)), + }]); + let mut expected_items = filtered_stateful_table + .filtered_items + .as_mut() + .unwrap() + .clone(); + expected_items.sort(); + + filtered_stateful_table.apply_sorting(); + + assert_eq!( + *filtered_stateful_table.filtered_items.as_ref().unwrap(), + expected_items + ); + assert!(filtered_stateful_table.sort_asc); + + filtered_stateful_table.apply_sorting(); + + expected_items.reverse(); + assert_eq!( + *filtered_stateful_table.filtered_items.as_ref().unwrap(), + expected_items + ); + assert!(!filtered_stateful_table.sort_asc); + } + + #[test] + fn test_filtered_stateful_table_current_selection() { + let mut filtered_stateful_table = create_test_filtered_stateful_table(); + + assert_str_eq!( + filtered_stateful_table.current_selection(), + &filtered_stateful_table.filtered_items.as_ref().unwrap()[0] + ); + + filtered_stateful_table + .filtered_state + .as_mut() + .unwrap() + .select(Some(1)); + + assert_str_eq!( + filtered_stateful_table.current_selection(), + &filtered_stateful_table.filtered_items.as_ref().unwrap()[1] + ); + } + + #[test] + fn test_stateful_table_select_index() { + let mut stateful_table = create_test_stateful_table(); + + assert_eq!(stateful_table.state.selected(), Some(0)); + + stateful_table.select_index(Some(1)); + + assert_eq!(stateful_table.state.selected(), Some(1)); + + stateful_table.select_index(None); + + assert_eq!(stateful_table.state.selected(), None); + } + + #[test] + fn test_filtered_stateful_table_select_index() { + let mut filtered_stateful_table = create_test_filtered_stateful_table(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(0) + ); + + filtered_stateful_table.select_index(Some(1)); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(1) + ); + + filtered_stateful_table.select_index(None); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + None + ); + } + + #[test] + fn test_stateful_table_scroll_up() { + let mut stateful_table = create_test_stateful_table(); + + assert_eq!(stateful_table.state.selected(), Some(0)); + + stateful_table.scroll_up(); + + assert_eq!(stateful_table.state.selected(), Some(1)); + + stateful_table.scroll_up(); + + assert_eq!(stateful_table.state.selected(), Some(0)); + } + + #[test] + fn test_filtered_stateful_table_scroll_up() { + let mut filtered_stateful_table = create_test_filtered_stateful_table(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(0) + ); + + filtered_stateful_table.scroll_up(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(1) + ); + + filtered_stateful_table.scroll_up(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + Some(0) + ); + } + + #[test] + fn test_stateful_table_apply_filter() { + let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); + stateful_table.set_items(vec!["this", "is", "a", "test"]); + stateful_table.filter = Some("i".into()); + let expected_items = vec!["this", "is"]; + let mut expected_state = TableState::default(); + expected_state.select(Some(0)); + + let has_matches = stateful_table.apply_filter(|&item| item); + + assert_eq!(stateful_table.filter, None); + assert_eq!(stateful_table.filtered_items, Some(expected_items)); + assert_eq!(stateful_table.filtered_state, Some(expected_state)); + assert!(has_matches); + } + + #[test] + fn test_stateful_table_apply_filter_no_matches() { + let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); + stateful_table.set_items(vec!["this", "is", "a", "test"]); + stateful_table.filter = Some("z".into()); + + let has_matches = stateful_table.apply_filter(|&item| item); + + assert_eq!(stateful_table.filter, None); + assert_eq!(stateful_table.filtered_items, None); + assert_eq!(stateful_table.filtered_state, None); + assert!(!has_matches); + } + + #[test] + fn test_stateful_table_reset_filter() { + let mut stateful_table = create_test_filtered_stateful_table(); + stateful_table.reset_filter(); + + assert_eq!(stateful_table.filter, None); + assert_eq!(stateful_table.filtered_items, None); + assert_eq!(stateful_table.filtered_state, None); + } + + #[test] + fn test_stateful_table_apply_search() { + let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); + stateful_table.set_items(vec!["this", "is", "a", "test"]); + stateful_table.search = Some("test".into()); + let mut expected_state = TableState::default(); + expected_state.select(Some(3)); + + let has_match = stateful_table.apply_search(|&item| item); + + assert_eq!(stateful_table.search, None); + assert_eq!(stateful_table.state, expected_state); + assert!(has_match); + } + + #[test] + fn test_stateful_table_apply_search_no_match() { + let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); + stateful_table.set_items(vec!["this", "is", "a", "test"]); + stateful_table.search = Some("shi-mon-a!".into()); + + let has_match = stateful_table.apply_search(|&item| item); + + assert_eq!(stateful_table.search, None); + assert!(!has_match); + } + + #[test] + fn test_filtered_stateful_table_apply_search() { + let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); + stateful_table.set_filtered_items(vec!["this", "is", "a", "test"]); + stateful_table.search = Some("test".into()); + let mut expected_state = TableState::default(); + expected_state.select(Some(3)); + + let has_match = stateful_table.apply_search(|&item| item); + + assert_eq!(stateful_table.search, None); + assert_eq!(stateful_table.filtered_state, Some(expected_state)); + assert!(has_match); + } + + #[test] + fn test_filtered_stateful_table_apply_search_no_match() { + let mut stateful_table: StatefulTable<&str> = StatefulTable::default(); + stateful_table.set_filtered_items(vec!["this", "is", "a", "test"]); + stateful_table.search = Some("shi-mon-a!".into()); + let mut expected_state = TableState::default(); + expected_state.select(Some(0)); + + let has_match = stateful_table.apply_search(|&item| item); + + assert_eq!(stateful_table.search, None); + assert_eq!(stateful_table.filtered_state, Some(expected_state)); + assert!(!has_match); + } + + #[test] + fn test_stateful_table_reset_search() { + let mut stateful_table = create_test_stateful_table(); + stateful_table.search = Some("test".into()); + stateful_table.reset_search(); + + assert_eq!(stateful_table.search, None); + } + + fn create_test_stateful_table() -> StatefulTable<&'static str> { + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items(vec!["Test 1", "Test 2"]); + + stateful_table + } + + fn create_test_filtered_stateful_table() -> StatefulTable<&'static str> { + let mut stateful_table = StatefulTable::default(); + stateful_table.set_filtered_items(vec!["Test 1", "Test 2"]); + + stateful_table + } +} diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 224cae3..6a9feef 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -20,7 +20,8 @@ use crate::models::servarr_data::radarr::modals::{ MovieDetailsModal, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; -use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText, StatefulTable}; +use crate::models::stateful_table::StatefulTable; +use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; use crate::network::{Network, NetworkEvent, RequestMethod, RequestProps}; use crate::utils::{convert_runtime, convert_to_gb}; diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 3163496..6b83b7c 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -17,7 +17,7 @@ mod test { Quality, QualityWrapper, Rating, RatingsList, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; - use crate::models::{HorizontallyScrollableText, StatefulTable}; + use crate::models::HorizontallyScrollableText; use crate::App; use super::super::*; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2b5e304..776f9d3 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -10,11 +10,12 @@ use ratatui::widgets::{Clear, List, ListItem}; use ratatui::Frame; use crate::app::App; -use crate::models::{HorizontallyScrollableText, Route, StatefulList, TabState}; +use crate::models::stateful_list::StatefulList; +use crate::models::{HorizontallyScrollableText, Route, TabState}; use crate::ui::radarr_ui::RadarrUi; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{ - background_block, borderless_block, centered_rect, layout_block, layout_block_top_border, + background_block, borderless_block, centered_rect, layout_block_top_border, layout_paragraph_borderless, logo_block, title_block, title_block_centered, }; use crate::ui::widgets::button::Button; @@ -210,25 +211,14 @@ pub fn draw_large_popup_over_background_fn_with_ui( draw_popup_over_ui::(f, app, area, background_fn, 75, 75); } -pub fn draw_drop_down_popup( - f: &mut Frame<'_>, - app: &mut App<'_>, - area: Rect, - background_fn: impl Fn(&mut Frame<'_>, &mut App<'_>, Rect), - drop_down_fn: impl Fn(&mut Frame<'_>, &mut App<'_>, Rect), -) { - draw_popup_over(f, app, area, background_fn, drop_down_fn, 20, 30); -} - fn draw_tabs(f: &mut Frame<'_>, area: Rect, title: &str, tab_state: &TabState) -> Rect { f.render_widget(title_block(title), area); let [header_area, content_area] = Layout::vertical([Constraint::Length(1), Constraint::Fill(0)]) .margin(1) .areas(area); - let [tabs_area, help_area] = Layout::horizontal([Constraint::Min(25), Constraint::Min(35)]) - .flex(Flex::SpaceBetween) - .areas(header_area); + let [tabs_area, help_area] = + Layout::horizontal([Constraint::Percentage(45), Constraint::Fill(0)]).areas(header_area); let titles = tab_state .tabs @@ -361,20 +351,6 @@ pub fn draw_prompt_box_with_checkboxes( f.render_widget(no_button, no_area); } -pub fn draw_selectable_list<'a, T>( - f: &mut Frame<'_>, - area: Rect, - content: &'a mut StatefulList, - item_mapper: impl Fn(&T) -> ListItem<'a>, -) { - let items: Vec> = content.items.iter().map(item_mapper).collect(); - let list = List::new(items) - .block(layout_block()) - .highlight_style(Style::new().highlight()); - - f.render_stateful_widget(list, area, &mut content.state); -} - pub fn draw_list_box<'a, T>( f: &mut Frame<'_>, area: Rect, diff --git a/src/ui/radarr_ui/collections/edit_collection_ui.rs b/src/ui/radarr_ui/collections/edit_collection_ui.rs index 116ae55..be3d839 100644 --- a/src/ui/radarr_ui/collections/edit_collection_ui.rs +++ b/src/ui/radarr_ui/collections/edit_collection_ui.rs @@ -16,9 +16,10 @@ use crate::ui::utils::{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::popup::Popup; +use crate::ui::widgets::selectable_list::SelectableList; use crate::ui::{ - draw_drop_down_popup, draw_large_popup_over_background_fn_with_ui, draw_medium_popup_over, - draw_popup, draw_selectable_list, DrawUi, + draw_large_popup_over_background_fn_with_ui, draw_medium_popup_over, draw_popup, DrawUi, }; #[cfg(test)] @@ -41,22 +42,12 @@ impl DrawUi for EditCollectionUi { let draw_edit_collection_prompt = |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| match active_radarr_block { ActiveRadarrBlock::EditCollectionSelectMinimumAvailability => { - draw_drop_down_popup( - f, - app, - prompt_area, - draw_edit_collection_confirmation_prompt, - draw_edit_collection_select_minimum_availability_popup, - ); + draw_edit_collection_confirmation_prompt(f, app, prompt_area); + draw_edit_collection_select_minimum_availability_popup(f, app); } ActiveRadarrBlock::EditCollectionSelectQualityProfile => { - draw_drop_down_popup( - f, - app, - prompt_area, - draw_edit_collection_confirmation_prompt, - draw_edit_collection_select_quality_profile_popup, - ); + draw_edit_collection_confirmation_prompt(f, app, prompt_area); + draw_edit_collection_select_quality_profile_popup(f, app); } ActiveRadarrBlock::EditCollectionPrompt | ActiveRadarrBlock::EditCollectionToggleMonitored @@ -180,14 +171,8 @@ fn draw_edit_collection_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_> f.render_widget(cancel_button, cancel_area); } -fn draw_edit_collection_select_minimum_availability_popup( - f: &mut Frame<'_>, - app: &mut App<'_>, - area: Rect, -) { - draw_selectable_list( - f, - area, +fn draw_edit_collection_select_minimum_availability_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let min_availability_list = SelectableList::new( &mut app .data .radarr_data @@ -197,16 +182,16 @@ fn draw_edit_collection_select_minimum_availability_popup( .minimum_availability_list, |minimum_availability| ListItem::new(minimum_availability.to_display_str().to_owned()), ); + let popup = Popup::new(min_availability_list, 20, 30); + + f.render_widget(popup, f.size()); } fn draw_edit_collection_select_quality_profile_popup( f: &mut Frame<'_>, app: &mut App<'_>, - area: Rect, ) { - draw_selectable_list( - f, - area, + let quality_profile_list = SelectableList::new( &mut app .data .radarr_data @@ -216,4 +201,7 @@ fn draw_edit_collection_select_quality_profile_popup( .quality_profile_list, |quality_profile| ListItem::new(quality_profile.clone()), ); + let popup = Popup::new(quality_profile_list, 20, 30); + + f.render_widget(popup, f.size()); } diff --git a/src/ui/radarr_ui/library/add_movie_ui.rs b/src/ui/radarr_ui/library/add_movie_ui.rs index 5d470ea..be0e9cc 100644 --- a/src/ui/radarr_ui/library/add_movie_ui.rs +++ b/src/ui/radarr_ui/library/add_movie_ui.rs @@ -21,9 +21,8 @@ use crate::ui::widgets::error_message::ErrorMessage; use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::Popup; -use crate::ui::{ - draw_drop_down_popup, draw_large_popup_over, draw_medium_popup_over, draw_selectable_list, DrawUi, -}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{draw_large_popup_over, draw_medium_popup_over, DrawUi}; use crate::utils::convert_runtime; use crate::{render_selectable_input_box, App}; @@ -266,40 +265,20 @@ fn draw_confirmation_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { match active_radarr_block { ActiveRadarrBlock::AddMovieSelectMonitor => { - draw_drop_down_popup( - f, - app, - area, - draw_confirmation_prompt, - draw_add_movie_select_monitor_popup, - ); + draw_confirmation_prompt(f, app, area); + draw_add_movie_select_monitor_popup(f, app); } ActiveRadarrBlock::AddMovieSelectMinimumAvailability => { - draw_drop_down_popup( - f, - app, - area, - draw_confirmation_prompt, - draw_add_movie_select_minimum_availability_popup, - ); + draw_confirmation_prompt(f, app, area); + draw_add_movie_select_minimum_availability_popup(f, app); } ActiveRadarrBlock::AddMovieSelectQualityProfile => { - draw_drop_down_popup( - f, - app, - area, - draw_confirmation_prompt, - draw_add_movie_select_quality_profile_popup, - ); + draw_confirmation_prompt(f, app, area); + draw_add_movie_select_quality_profile_popup(f, app); } ActiveRadarrBlock::AddMovieSelectRootFolder => { - draw_drop_down_popup( - f, - app, - area, - draw_confirmation_prompt, - draw_add_movie_select_root_folder_popup, - ); + draw_confirmation_prompt(f, app, area); + draw_add_movie_select_root_folder_popup(f, app); } ActiveRadarrBlock::AddMoviePrompt | ActiveRadarrBlock::AddMovieTagsInput => { draw_confirmation_prompt(f, app, area) @@ -437,10 +416,8 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { f.render_widget(cancel_button, cancel_area); } -fn draw_add_movie_select_monitor_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_selectable_list( - f, - area, +fn draw_add_movie_select_monitor_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_list = SelectableList::new( &mut app .data .radarr_data @@ -450,16 +427,13 @@ fn draw_add_movie_select_monitor_popup(f: &mut Frame<'_>, app: &mut App<'_>, are .monitor_list, |monitor| ListItem::new(monitor.to_display_str().to_owned()), ); + let popup = Popup::new(monitor_list, 20, 30); + + f.render_widget(popup, f.size()); } -fn draw_add_movie_select_minimum_availability_popup( - f: &mut Frame<'_>, - app: &mut App<'_>, - area: Rect, -) { - draw_selectable_list( - f, - area, +fn draw_add_movie_select_minimum_availability_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let minimum_availability_list = SelectableList::new( &mut app .data .radarr_data @@ -469,12 +443,13 @@ fn draw_add_movie_select_minimum_availability_popup( .minimum_availability_list, |minimum_availability| ListItem::new(minimum_availability.to_display_str().to_owned()), ); + let popup = Popup::new(minimum_availability_list, 20, 30); + + f.render_widget(popup, f.size()); } -fn draw_add_movie_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_selectable_list( - f, - area, +fn draw_add_movie_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( &mut app .data .radarr_data @@ -484,12 +459,13 @@ fn draw_add_movie_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App< .quality_profile_list, |quality_profile| ListItem::new(quality_profile.clone()), ); + let popup = Popup::new(quality_profile_list, 20, 30); + + f.render_widget(popup, f.size()); } -fn draw_add_movie_select_root_folder_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_selectable_list( - f, - area, +fn draw_add_movie_select_root_folder_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let root_folder_list = SelectableList::new( &mut app .data .radarr_data @@ -499,4 +475,7 @@ fn draw_add_movie_select_root_folder_popup(f: &mut Frame<'_>, app: &mut App<'_>, .root_folder_list, |root_folder| ListItem::new(root_folder.path.to_owned()), ); + let popup = Popup::new(root_folder_list, 20, 30); + + f.render_widget(popup, f.size()); } diff --git a/src/ui/radarr_ui/library/edit_movie_ui.rs b/src/ui/radarr_ui/library/edit_movie_ui.rs index c77d667..57e643c 100644 --- a/src/ui/radarr_ui/library/edit_movie_ui.rs +++ b/src/ui/radarr_ui/library/edit_movie_ui.rs @@ -18,9 +18,10 @@ use crate::ui::utils::{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::popup::Popup; +use crate::ui::widgets::selectable_list::SelectableList; use crate::ui::{ - draw_drop_down_popup, draw_large_popup_over_background_fn_with_ui, draw_medium_popup_over, - draw_popup, draw_selectable_list, DrawUi, + draw_large_popup_over_background_fn_with_ui, draw_medium_popup_over, draw_popup, DrawUi, }; #[cfg(test)] @@ -43,22 +44,12 @@ impl DrawUi for EditMovieUi { let draw_edit_movie_prompt = |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| match active_radarr_block { ActiveRadarrBlock::EditMovieSelectMinimumAvailability => { - draw_drop_down_popup( - f, - app, - prompt_area, - draw_edit_movie_confirmation_prompt, - draw_edit_movie_select_minimum_availability_popup, - ); + draw_edit_movie_confirmation_prompt(f, app, prompt_area); + draw_edit_movie_select_minimum_availability_popup(f, app); } ActiveRadarrBlock::EditMovieSelectQualityProfile => { - draw_drop_down_popup( - f, - app, - prompt_area, - draw_edit_movie_confirmation_prompt, - draw_edit_movie_select_quality_profile_popup, - ); + draw_edit_movie_confirmation_prompt(f, app, prompt_area); + draw_edit_movie_select_quality_profile_popup(f, app); } ActiveRadarrBlock::EditMoviePrompt | ActiveRadarrBlock::EditMovieToggleMonitored @@ -190,14 +181,8 @@ fn draw_edit_movie_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, are f.render_widget(cancel_button, cancel_area); } -fn draw_edit_movie_select_minimum_availability_popup( - f: &mut Frame<'_>, - app: &mut App<'_>, - area: Rect, -) { - draw_selectable_list( - f, - area, +fn draw_edit_movie_select_minimum_availability_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let minimum_availability_list = SelectableList::new( &mut app .data .radarr_data @@ -207,12 +192,13 @@ fn draw_edit_movie_select_minimum_availability_popup( .minimum_availability_list, |minimum_availability| ListItem::new(minimum_availability.to_display_str().to_owned()), ); + let popup = Popup::new(minimum_availability_list, 20, 30); + + f.render_widget(popup, f.size()); } -fn draw_edit_movie_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_selectable_list( - f, - area, +fn draw_edit_movie_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( &mut app .data .radarr_data @@ -222,4 +208,7 @@ fn draw_edit_movie_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App .quality_profile_list, |quality_profile| ListItem::new(quality_profile.clone()), ); + let popup = Popup::new(quality_profile_list, 20, 30); + + f.render_widget(popup, f.size()); } diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index e1fd620..b8ae581 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -3,11 +3,11 @@ use std::iter; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Style, Stylize}; use ratatui::text::{Line, Span, Text}; -use ratatui::widgets::{Cell, ListItem, Paragraph, Row, Wrap}; +use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use ratatui::Frame; use crate::app::App; -use crate::models::radarr_models::{Credit, MovieHistoryItem, Release, ReleaseField}; +use crate::models::radarr_models::{Credit, MovieHistoryItem, Release}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; use crate::models::Route; use crate::ui::radarr_ui::library::draw_library; @@ -18,8 +18,8 @@ use crate::ui::utils::{ use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::{ - draw_drop_down_popup, draw_large_popup_over, draw_prompt_box, draw_prompt_box_with_content, - draw_prompt_popup_over, draw_selectable_list, draw_small_popup_over, draw_tabs, DrawUi, + draw_large_popup_over, draw_prompt_box, draw_prompt_box_with_content, draw_prompt_popup_over, + draw_small_popup_over, draw_tabs, DrawUi, }; use crate::utils::convert_to_gb; @@ -63,26 +63,6 @@ impl DrawUi for MovieDetailsUi { draw_movie_info, draw_update_and_scan_prompt, ), - ActiveRadarrBlock::ManualSearchSortPrompt => draw_drop_down_popup( - f, - app, - content_area, - draw_movie_info, - |f, app, content_area| { - draw_selectable_list( - f, - content_area, - &mut app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases_sort, - |sort_option| ListItem::new(sort_option.to_string()), - ) - }, - ), ActiveRadarrBlock::ManualSearchConfirmPrompt => draw_small_popup_over( f, app, @@ -388,142 +368,110 @@ fn draw_movie_crew(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - let (current_selection, is_empty, sort_ascending) = - match app.data.radarr_data.movie_details_modal.as_ref() { + if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + let (current_selection, is_empty) = match app.data.radarr_data.movie_details_modal.as_ref() { Some(movie_details_modal) if !movie_details_modal.movie_releases.items.is_empty() => ( movie_details_modal .movie_releases .current_selection() .clone(), movie_details_modal.movie_releases.items.is_empty(), - movie_details_modal.sort_ascending, ), - _ => (Release::default(), true, None), + _ => (Release::default(), true), }; - let current_route = *app.get_current_route(); - let help_footer = app - .data - .radarr_data - .movie_info_tabs - .get_active_tab_contextual_help(); - let mut table_headers_vec = vec![ - "Source".to_owned(), - "Age".to_owned(), - "⛔".to_owned(), - "Title".to_owned(), - "Indexer".to_owned(), - "Size".to_owned(), - "Peers".to_owned(), - "Language".to_owned(), - "Quality".to_owned(), - ]; - - if let Some(ascending) = sort_ascending { - let direction = if ascending { " ▲" } else { " ▼" }; - - match app + let current_route = *app.get_current_route(); + let help_footer = app .data .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases_sort - .current_selection() - { - ReleaseField::Source => table_headers_vec[0].push_str(direction), - ReleaseField::Age => table_headers_vec[1].push_str(direction), - ReleaseField::Rejected => table_headers_vec[2].push_str(direction), - ReleaseField::Title => table_headers_vec[3].push_str(direction), - ReleaseField::Indexer => table_headers_vec[4].push_str(direction), - ReleaseField::Size => table_headers_vec[5].push_str(direction), - ReleaseField::Peers => table_headers_vec[6].push_str(direction), - ReleaseField::Language => table_headers_vec[7].push_str(direction), - ReleaseField::Quality => table_headers_vec[8].push_str(direction), - } - } - let content = Some( - &mut app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases, - ); - let releases_row_mapping = |release: &Release| { - let Release { - protocol, - age, - title, - indexer, - size, - rejected, - seeders, - leechers, - languages, - quality, - .. - } = release; - let age = format!("{age} days"); - title.scroll_left_or_reset( - get_width_from_percentage(area, 30), - current_selection == *release - && current_route != ActiveRadarrBlock::ManualSearchConfirmPrompt.into(), - app.tick_count % app.ticks_until_scroll == 0, + .movie_info_tabs + .get_active_tab_contextual_help(); + let content = Some( + &mut app + .data + .radarr_data + .movie_details_modal + .as_mut() + .unwrap() + .movie_releases, ); - let size = convert_to_gb(*size); - let rejected_str = if *rejected { "⛔" } else { "" }; - let peers = if seeders.is_none() || leechers.is_none() { - Text::from("") - } else { - let seeders = seeders.clone().unwrap().as_u64().unwrap(); - let leechers = leechers.clone().unwrap().as_u64().unwrap(); - - decorate_peer_style( + let releases_row_mapping = |release: &Release| { + let Release { + protocol, + age, + title, + indexer, + size, + rejected, seeders, leechers, - Text::from(format!("{seeders} / {leechers}")), - ) + languages, + quality, + .. + } = release; + let age = format!("{age} days"); + title.scroll_left_or_reset( + get_width_from_percentage(area, 30), + current_selection == *release + && current_route != ActiveRadarrBlock::ManualSearchConfirmPrompt.into(), + app.tick_count % app.ticks_until_scroll == 0, + ); + let size = convert_to_gb(*size); + let rejected_str = if *rejected { "⛔" } else { "" }; + let peers = if seeders.is_none() || leechers.is_none() { + Text::from("") + } else { + let seeders = seeders.clone().unwrap().as_u64().unwrap(); + let leechers = leechers.clone().unwrap().as_u64().unwrap(); + + decorate_peer_style( + seeders, + leechers, + Text::from(format!("{seeders} / {leechers}")), + ) + }; + + let language = if languages.is_some() { + languages.clone().unwrap()[0].name.clone() + } else { + String::new() + }; + let quality = quality.quality.name.clone(); + + Row::new(vec![ + Cell::from(protocol.clone()), + Cell::from(age), + Cell::from(rejected_str), + Cell::from(title.to_string()), + Cell::from(indexer.clone()), + Cell::from(format!("{size:.1} GB")), + Cell::from(peers), + Cell::from(language), + Cell::from(quality), + ]) + .primary() }; + let releases_table = ManagarrTable::new(content, releases_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading || is_empty) + .footer(help_footer) + .sorting(active_radarr_block == ActiveRadarrBlock::ManualSearchSortPrompt) + .headers([ + "Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Language", "Quality", + ]) + .constraints([ + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(5), + Constraint::Percentage(30), + Constraint::Percentage(18), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Percentage(7), + Constraint::Percentage(10), + ]); - let language = if languages.is_some() { - languages.clone().unwrap()[0].name.clone() - } else { - String::new() - }; - let quality = quality.quality.name.clone(); - - Row::new(vec![ - Cell::from(protocol.clone()), - Cell::from(age), - Cell::from(rejected_str), - Cell::from(title.to_string()), - Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), - Cell::from(peers), - Cell::from(language), - Cell::from(quality), - ]) - .primary() - }; - let releases_table = ManagarrTable::new(content, releases_row_mapping) - .block(layout_block_top_border()) - .loading(app.is_loading || is_empty) - .footer(help_footer) - .headers(table_headers_vec.iter().map(|s| &**s)) - .constraints([ - Constraint::Length(9), - Constraint::Length(10), - Constraint::Length(5), - Constraint::Percentage(30), - Constraint::Percentage(18), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Percentage(7), - Constraint::Percentage(10), - ]); - - f.render_widget(releases_table, area); + f.render_widget(releases_table, area); + } } fn draw_manual_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { diff --git a/src/ui/widgets/managarr_table.rs b/src/ui/widgets/managarr_table.rs index 5a56286..b3344ae 100644 --- a/src/ui/widgets/managarr_table.rs +++ b/src/ui/widgets/managarr_table.rs @@ -1,19 +1,23 @@ -use crate::models::StatefulTable; +use crate::models::stateful_table::StatefulTable; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::layout_block_top_border; use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Popup; +use crate::ui::widgets::selectable_list::SelectableList; use crate::ui::HIGHLIGHT_SYMBOL; use ratatui::buffer::Buffer; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::prelude::{Style, Stylize, Text}; -use ratatui::widgets::{Block, Paragraph, Row, StatefulWidget, Table, Widget}; +use ratatui::widgets::{Block, ListItem, Paragraph, Row, StatefulWidget, Table, Widget}; +use std::fmt::Debug; pub struct ManagarrTable<'a, T, F> where F: Fn(&T) -> Row<'a>, + T: Clone + PartialEq + Eq + Debug, { content: Option<&'a mut StatefulTable>, - table_headers: Vec>, + table_headers: Vec, constraints: Vec, row_mapper: F, footer: Option, @@ -22,11 +26,13 @@ where margin: u16, is_loading: bool, highlight_rows: bool, + is_sorting: bool, } impl<'a, T, F> ManagarrTable<'a, T, F> where F: Fn(&T) -> Row<'a>, + T: Clone + PartialEq + Eq + Debug, { pub fn new(content: Option<&'a mut StatefulTable>, row_mapper: F) -> Self { Self { @@ -40,13 +46,14 @@ where margin: 0, is_loading: false, highlight_rows: true, + is_sorting: false, } } pub fn headers(mut self, headers: I) -> Self where I: IntoIterator, - I::Item: Into>, + I::Item: Into, { self.table_headers = headers.into_iter().map(Into::into).collect(); self @@ -91,7 +98,13 @@ where self } + pub fn sorting(mut self, is_sorting: bool) -> Self { + self.is_sorting = is_sorting; + self + } + fn render_table(self, area: Rect, buf: &mut Buffer) { + let table_headers = self.parse_headers(); let table_area = if let Some(ref footer) = self.footer { let [content_area, footer_area] = Layout::vertical([Constraint::Fill(0), Constraint::Length(2)]) @@ -121,10 +134,7 @@ where if !table_contents.is_empty() { let rows = table_contents.iter().map(&self.row_mapper); - let headers = Row::new(self.table_headers) - .default() - .bold() - .bottom_margin(0); + let headers = Row::new(table_headers).default().bold().bottom_margin(0); let mut table = Table::new(rows, &self.constraints) .header(headers) @@ -137,6 +147,13 @@ where } StatefulWidget::render(table, table_area, buf, table_state); + + if content.sort.is_some() && self.is_sorting { + let selectable_list = SelectableList::new(content.sort.as_mut().unwrap(), |item| { + ListItem::new(Text::from(item.name)) + }); + Popup::new(selectable_list, 20, 50).render(table_area, buf); + } } else { loading_block.render(table_area, buf); } @@ -144,11 +161,34 @@ where loading_block.render(table_area, buf); } } + + fn parse_headers(&self) -> Vec> { + if let Some(ref content) = self.content { + if let Some(ref sort_list) = content.sort { + if !self.is_sorting { + let mut new_headers = self.table_headers.clone(); + let idx = sort_list.state.selected().unwrap_or(0); + let direction = if content.sort_asc { " ▲" } else { " ▼" }; + new_headers[idx].push_str(direction); + + return new_headers.into_iter().map(Text::from).collect(); + } + } + } + + self + .table_headers + .clone() + .into_iter() + .map(Text::from) + .collect() + } } impl<'a, T, F> Widget for ManagarrTable<'a, T, F> where F: Fn(&T) -> Row<'a>, + T: Clone + PartialEq + Eq + Debug, { fn render(self, area: Rect, buf: &mut Buffer) { self.render_table(area, buf); diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs index 7e85c31..036040a 100644 --- a/src/ui/widgets/mod.rs +++ b/src/ui/widgets/mod.rs @@ -5,3 +5,4 @@ pub(super) mod input_box; pub(super) mod loading_block; pub(super) mod managarr_table; pub(super) mod popup; +pub(super) mod selectable_list; diff --git a/src/ui/widgets/selectable_list.rs b/src/ui/widgets/selectable_list.rs new file mode 100644 index 0000000..c4357a4 --- /dev/null +++ b/src/ui/widgets/selectable_list.rs @@ -0,0 +1,47 @@ +use crate::models::stateful_list::StatefulList; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::layout_block; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Style; +use ratatui::widgets::{List, ListItem, StatefulWidget}; + +pub struct SelectableList<'a, T, F> +where + F: Fn(&T) -> ListItem<'a>, +{ + content: &'a mut StatefulList, + row_mapper: F, +} + +impl<'a, T, F> SelectableList<'a, T, F> +where + F: Fn(&T) -> ListItem<'a>, +{ + pub fn new(content: &'a mut StatefulList, row_mapper: F) -> Self { + Self { + content, + row_mapper, + } + } + + fn render_list(self, area: Rect, buf: &mut Buffer) { + let items: Vec> = self.content.items.iter().map(&self.row_mapper).collect(); + + let selectable_list = List::new(items) + .block(layout_block()) + .highlight_style(Style::new().highlight()); + + StatefulWidget::render(selectable_list, area, buf, &mut self.content.state); + } +} + +impl<'a, T, F> Widget for SelectableList<'a, T, F> +where + F: Fn(&T) -> ListItem<'a>, +{ + fn render(self, area: Rect, buf: &mut Buffer) { + self.render_list(area, buf); + } +}