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); + } +}