From e96af7410ecc1a3771fcbd187144911a5c803415 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 8 Aug 2025 17:04:28 -0600 Subject: [PATCH] feat: Pagination support for jumping 20 items at a time in all table views [#45] --- src/app/app_tests.rs | 8 +- src/app/key_binding.rs | 12 ++ src/app/key_binding_tests.rs | 2 + src/app/mod.rs | 2 +- src/event/key.rs | 12 ++ src/event/key_tests.rs | 12 ++ src/handlers/table_handler.rs | 26 +++++ src/handlers/table_handler_tests.rs | 42 +++++++ src/models/mod.rs | 5 + src/models/stateful_table.rs | 77 ++++++++++++- src/models/stateful_table_tests.rs | 171 +++++++++++++++++++++++++++- 11 files changed, 362 insertions(+), 7 deletions(-) diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 9f250dd..c359177 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -40,7 +40,7 @@ mod tests { title: "Sonarr Test".to_owned(), route: ActiveSonarrBlock::default().into(), help: format!( - "<↑↓> scroll | ←→ change tab | {} ", + "<↑↓> scroll | page up/down | ←→ change tab | {} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES) ), contextual_help: None, @@ -50,7 +50,7 @@ mod tests { title: "Radarr 1".to_owned(), route: ActiveRadarrBlock::default().into(), help: format!( - "<↑↓> scroll | ←→ change tab | {} ", + "<↑↓> scroll | page up/down | ←→ change tab | {} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES) ), contextual_help: None, @@ -60,7 +60,7 @@ mod tests { title: "Radarr Test".to_owned(), route: ActiveRadarrBlock::default().into(), help: format!( - "<↑↓> scroll | ←→ change tab | {} ", + "<↑↓> scroll | page up/down | ←→ change tab | {} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES) ), contextual_help: None, @@ -70,7 +70,7 @@ mod tests { title: "Sonarr 1".to_owned(), route: ActiveSonarrBlock::default().into(), help: format!( - "<↑↓> scroll | ←→ change tab | {} ", + "<↑↓> scroll | page up/down | ←→ change tab | {} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES) ), contextual_help: None, diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index 7a4d94d..547cf45 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -14,6 +14,8 @@ generate_keybindings! { down, left, right, + pg_down, + pg_up, backspace, next_servarr, previous_servarr, @@ -74,6 +76,16 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { alt: Some(Key::Char('l')), desc: "right", }, + pg_down: KeyBinding { + key: Key::PgDown, + alt: Some(Key::Ctrl('d')), + desc: "page down", + }, + pg_up: KeyBinding { + key: Key::PgUp, + alt: Some(Key::Ctrl('u')), + desc: "page up", + }, backspace: KeyBinding { key: Key::Backspace, alt: Some(Key::Ctrl('h')), diff --git a/src/app/key_binding_tests.rs b/src/app/key_binding_tests.rs index 9521098..c9604ad 100644 --- a/src/app/key_binding_tests.rs +++ b/src/app/key_binding_tests.rs @@ -13,6 +13,8 @@ mod test { #[case(DEFAULT_KEYBINDINGS.down, Key::Down, Some(Key::Char('j')), "down")] #[case(DEFAULT_KEYBINDINGS.left, Key::Left, Some(Key::Char('h')), "left")] #[case(DEFAULT_KEYBINDINGS.right, Key::Right, Some(Key::Char('l')), "right")] + #[case(DEFAULT_KEYBINDINGS.pg_down, Key::PgDown, Some(Key::Ctrl('d')), "page down")] + #[case(DEFAULT_KEYBINDINGS.pg_up, Key::PgUp, Some(Key::Ctrl('u')), "page up")] #[case(DEFAULT_KEYBINDINGS.backspace, Key::Backspace, Some(Key::Ctrl('h')), "backspace")] #[case(DEFAULT_KEYBINDINGS.next_servarr, Key::Tab, None, "next servarr")] #[case(DEFAULT_KEYBINDINGS.previous_servarr, Key::BackTab, None, "previous servarr")] diff --git a/src/app/mod.rs b/src/app/mod.rs index 831d5b2..9220a07 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -52,7 +52,7 @@ impl App<'_> { ) -> Self { let mut server_tabs = Vec::new(); let help = format!( - "<↑↓> scroll | ←→ change tab | {} ", + "<↑↓> scroll | page up/down | ←→ change tab | {} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES) ); diff --git a/src/event/key.rs b/src/event/key.rs index 50d344c..456ba83 100644 --- a/src/event/key.rs +++ b/src/event/key.rs @@ -13,6 +13,8 @@ pub enum Key { Down, Left, Right, + PgDown, + PgUp, Enter, Esc, Backspace, @@ -35,6 +37,8 @@ impl Display for Key { Key::Down => write!(f, "<↓>"), Key::Left => write!(f, "<←>"), Key::Right => write!(f, "<→>"), + Key::PgDown => write!(f, ""), + Key::PgUp => write!(f, ""), Key::Enter => write!(f, ""), Key::Esc => write!(f, ""), Key::Backspace => write!(f, ""), @@ -66,6 +70,14 @@ impl From for Key { code: KeyCode::Right, .. } => Key::Right, + KeyEvent { + code: KeyCode::PageDown, + .. + } => Key::PgDown, + KeyEvent { + code: KeyCode::PageUp, + .. + } => Key::PgUp, KeyEvent { code: KeyCode::Backspace, .. diff --git a/src/event/key_tests.rs b/src/event/key_tests.rs index dc2dda3..85ab6f3 100644 --- a/src/event/key_tests.rs +++ b/src/event/key_tests.rs @@ -11,6 +11,8 @@ mod tests { #[case(Key::Down, "↓")] #[case(Key::Left, "←")] #[case(Key::Right, "→")] + #[case(Key::PgDown, "C-d")] + #[case(Key::PgUp, "C-u")] #[case(Key::Enter, "enter")] #[case(Key::Esc, "esc")] #[case(Key::Backspace, "backspace")] @@ -45,6 +47,16 @@ mod tests { assert_eq!(Key::from(KeyEvent::from(KeyCode::Right)), Key::Right); } + #[test] + fn test_key_from_page_down() { + assert_eq!(Key::from(KeyEvent::from(KeyCode::PageDown)), Key::PgDown); + } + + #[test] + fn test_key_from_page_up() { + assert_eq!(Key::from(KeyEvent::from(KeyCode::PageUp)), Key::PgUp); + } + #[test] fn test_key_from_backspace() { assert_eq!( diff --git a/src/handlers/table_handler.rs b/src/handlers/table_handler.rs index f0e32fc..fc7ae1a 100644 --- a/src/handlers/table_handler.rs +++ b/src/handlers/table_handler.rs @@ -44,6 +44,8 @@ macro_rules! handle_table_events { match $self.key { _ if $crate::matches_key!(up, $self.key, $self.ignore_special_keys()) => $self.[](config), _ if $crate::matches_key!(down, $self.key, $self.ignore_special_keys()) => $self.[](config), + _ if $crate::matches_key!(pg_up, $self.key, $self.ignore_special_keys()) => $self.[](config), + _ if $crate::matches_key!(pg_down, $self.key, $self.ignore_special_keys()) => $self.[](config), _ if $crate::matches_key!(home, $self.key) => $self.[](config), _ if $crate::matches_key!(end, $self.key) => $self.[](config), _ if $crate::matches_key!(left, $self.key, $self.ignore_special_keys()) @@ -116,6 +118,30 @@ macro_rules! handle_table_events { } } + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { + use $crate::models::Paginated; + + match $self.app.get_current_route() { + _ if config.table_block == $self.app.get_current_route() => { + $table.page_down(); + true + } + _ => false, + } + } + + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { + use $crate::models::Paginated; + + match $self.app.get_current_route() { + _ if config.table_block == $self.app.get_current_route() => { + $table.page_up(); + true + } + _ => false, + } + } + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { use $crate::models::Scrollable; diff --git a/src/handlers/table_handler_tests.rs b/src/handlers/table_handler_tests.rs index e84944d..688bfcd 100644 --- a/src/handlers/table_handler_tests.rs +++ b/src/handlers/table_handler_tests.rs @@ -429,6 +429,48 @@ mod tests { } } + mod test_handle_pagination_scroll { + use super::*; + use crate::handlers::table_handler::table_handler_tests::tests::TableHandlerUnit; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use pretty_assertions::assert_str_eq; + use rstest::rstest; + use std::iter; + + #[rstest] + fn test_table_pagination_scroll( + #[values(DEFAULT_KEYBINDINGS.pg_up.key, DEFAULT_KEYBINDINGS.pg_down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + let mut curr = 0; + let movies_vec = iter::repeat_with(|| { + let tmp = curr; + curr += 1; + Movie { + title: format!("Test {tmp}").into(), + ..Movie::default() + } + }) + .take(100) + .collect(); + app.data.radarr_data.movies.set_items(movies_vec); + TableHandlerUnit::new(key, &mut app, ActiveRadarrBlock::Movies, None).handle(); + + if key == Key::PgUp { + assert_str_eq!( + app.data.radarr_data.movies.current_selection().title.text, + "Test 79" + ); + } else { + assert_str_eq!( + app.data.radarr_data.movies.current_selection().title.text, + "Test 20" + ); + } + } + } + mod test_handle_left_right_action { use pretty_assertions::assert_eq; use std::sync::atomic::Ordering::SeqCst; diff --git a/src/models/mod.rs b/src/models/mod.rs index 75cb072..4d57329 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -49,6 +49,11 @@ pub trait Scrollable { fn scroll_to_bottom(&mut self); } +pub trait Paginated { + fn page_down(&mut self); + fn page_up(&mut self); +} + #[derive(Default)] pub struct ScrollableText { pub items: Vec, diff --git a/src/models/stateful_table.rs b/src/models/stateful_table.rs index fcc4dd6..b96628c 100644 --- a/src/models/stateful_table.rs +++ b/src/models/stateful_table.rs @@ -1,5 +1,7 @@ use crate::models::stateful_list::StatefulList; -use crate::models::{strip_non_search_characters, HorizontallyScrollableText, Scrollable}; +use crate::models::{ + strip_non_search_characters, HorizontallyScrollableText, Paginated, Scrollable, +}; use ratatui::widgets::TableState; use std::cmp::Ordering; use std::fmt::Debug; @@ -151,6 +153,79 @@ where } } +impl Paginated for StatefulTable +where + T: Clone + PartialEq + Eq + Debug, +{ + fn page_down(&mut self) { + if let Some(filtered_items) = self.filtered_items.as_ref() { + if filtered_items.is_empty() { + return; + } + + match self.filtered_state.as_ref().unwrap().selected() { + Some(i) => { + self + .filtered_state + .as_mut() + .unwrap() + .select(Some(i.saturating_add(20) % (filtered_items.len() - 1))); + } + None => self.filtered_state.as_mut().unwrap().select_first(), + }; + + return; + } + + if self.items.is_empty() { + return; + } + + match self.state.selected() { + Some(i) => { + self + .state + .select(Some(i.saturating_add(20) % (self.items.len() - 1))); + } + None => self.state.select_first(), + }; + } + + fn page_up(&mut self) { + if let Some(filtered_items) = self.filtered_items.as_ref() { + if filtered_items.is_empty() { + return; + } + + match self.filtered_state.as_ref().unwrap().selected() { + Some(i) => { + let len = filtered_items.len() - 1; + self + .filtered_state + .as_mut() + .unwrap() + .select(Some((i + len - (20 % len)) % len)); + } + None => self.filtered_state.as_mut().unwrap().select_last(), + }; + + return; + } + + if self.items.is_empty() { + return; + } + + match self.state.selected() { + Some(i) => { + let len = self.items.len() - 1; + self.state.select(Some((i + len - (20 % len)) % len)); + } + None => self.state.select_last(), + }; + } +} + impl StatefulTable where T: Clone + PartialEq + Eq + Debug + Default, diff --git a/src/models/stateful_table_tests.rs b/src/models/stateful_table_tests.rs index f87e417..924160b 100644 --- a/src/models/stateful_table_tests.rs +++ b/src/models/stateful_table_tests.rs @@ -1,9 +1,10 @@ #[cfg(test)] mod tests { use crate::models::stateful_table::{SortOption, StatefulTable}; - use crate::models::Scrollable; + use crate::models::{Paginated, Scrollable}; use pretty_assertions::{assert_eq, assert_str_eq}; use ratatui::widgets::TableState; + use std::iter; #[test] fn test_stateful_table_scrolling_on_empty_table_performs_no_op() { @@ -190,6 +191,174 @@ mod tests { ); } + #[test] + fn test_stateful_table_pagination_on_empty_table_performs_no_op() { + let mut stateful_table: StatefulTable = StatefulTable::default(); + + assert_eq!(stateful_table.state.selected(), None); + + stateful_table.page_down(); + + assert_eq!(stateful_table.state.selected(), None); + + stateful_table.page_up(); + + assert_eq!(stateful_table.state.selected(), None); + } + + #[test] + fn test_stateful_table_filtered_pagination_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.page_down(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + None + ); + + filtered_stateful_table.page_up(); + + assert_eq!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + None + ); + } + + #[test] + fn test_stateful_table_pagination() { + let mut stateful_table = StatefulTable::default(); + let mut curr = 0; + stateful_table.set_filtered_items( + iter::repeat_with(|| { + let tmp = curr; + curr += 1; + tmp + }) + .take(100) + .collect(), + ); + + assert_eq!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + Some(0) + ); + + stateful_table.page_down(); + + assert_eq!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + Some(20) + ); + + stateful_table.page_up(); + + assert_eq!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + Some(0) + ); + + stateful_table.page_up(); + + assert_eq!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + Some(stateful_table.filtered_items.as_ref().unwrap().len() - 21) + ); + + stateful_table.page_down(); + + assert_eq!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + Some(0) + ); + + stateful_table.scroll_down(); + stateful_table.page_up(); + + assert_eq!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + Some(stateful_table.filtered_items.as_ref().unwrap().len() - 20) + ); + + stateful_table.scroll_down(); + stateful_table.page_down(); + + assert_eq!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + Some(2) + ); + } + + #[test] + fn test_stateful_table_filtered_items_pagination() { + let mut stateful_table = StatefulTable::default(); + let mut curr = 0; + stateful_table.set_items( + iter::repeat_with(|| { + let tmp = curr; + curr += 1; + tmp + }) + .take(100) + .collect(), + ); + + assert_eq!(stateful_table.state.selected(), Some(0)); + + stateful_table.page_down(); + + assert_eq!(stateful_table.state.selected(), Some(20)); + + stateful_table.page_up(); + + assert_eq!(stateful_table.state.selected(), Some(0)); + + stateful_table.page_up(); + + assert_eq!( + stateful_table.state.selected(), + Some(stateful_table.items.len() - 21) + ); + + stateful_table.page_down(); + + assert_eq!(stateful_table.state.selected(), Some(0)); + + stateful_table.scroll_down(); + stateful_table.page_up(); + + assert_eq!( + stateful_table.state.selected(), + Some(stateful_table.items.len() - 20) + ); + + stateful_table.scroll_down(); + stateful_table.page_down(); + + assert_eq!(stateful_table.state.selected(), Some(2)); + } + #[test] fn test_stateful_table_set_items() { let items_vec = vec!["Test 1", "Test 2", "Test 3"];