feat: Pagination support for jumping 20 items at a time in all table views [#45]

This commit is contained in:
2025-08-08 17:04:28 -06:00
parent 345bb8ce03
commit e96af7410e
11 changed files with 362 additions and 7 deletions
+4 -4
View File
@@ -40,7 +40,7 @@ mod tests {
title: "Sonarr Test".to_owned(), title: "Sonarr Test".to_owned(),
route: ActiveSonarrBlock::default().into(), route: ActiveSonarrBlock::default().into(),
help: format!( help: format!(
"<↑↓> scroll | ←→ change tab | {} ", "<↑↓> scroll | <C-u/d> page up/down | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES) build_context_clue_string(&SERVARR_CONTEXT_CLUES)
), ),
contextual_help: None, contextual_help: None,
@@ -50,7 +50,7 @@ mod tests {
title: "Radarr 1".to_owned(), title: "Radarr 1".to_owned(),
route: ActiveRadarrBlock::default().into(), route: ActiveRadarrBlock::default().into(),
help: format!( help: format!(
"<↑↓> scroll | ←→ change tab | {} ", "<↑↓> scroll | <C-u/d> page up/down | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES) build_context_clue_string(&SERVARR_CONTEXT_CLUES)
), ),
contextual_help: None, contextual_help: None,
@@ -60,7 +60,7 @@ mod tests {
title: "Radarr Test".to_owned(), title: "Radarr Test".to_owned(),
route: ActiveRadarrBlock::default().into(), route: ActiveRadarrBlock::default().into(),
help: format!( help: format!(
"<↑↓> scroll | ←→ change tab | {} ", "<↑↓> scroll | <C-u/d> page up/down | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES) build_context_clue_string(&SERVARR_CONTEXT_CLUES)
), ),
contextual_help: None, contextual_help: None,
@@ -70,7 +70,7 @@ mod tests {
title: "Sonarr 1".to_owned(), title: "Sonarr 1".to_owned(),
route: ActiveSonarrBlock::default().into(), route: ActiveSonarrBlock::default().into(),
help: format!( help: format!(
"<↑↓> scroll | ←→ change tab | {} ", "<↑↓> scroll | <C-u/d> page up/down | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES) build_context_clue_string(&SERVARR_CONTEXT_CLUES)
), ),
contextual_help: None, contextual_help: None,
+12
View File
@@ -14,6 +14,8 @@ generate_keybindings! {
down, down,
left, left,
right, right,
pg_down,
pg_up,
backspace, backspace,
next_servarr, next_servarr,
previous_servarr, previous_servarr,
@@ -74,6 +76,16 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings {
alt: Some(Key::Char('l')), alt: Some(Key::Char('l')),
desc: "right", 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 { backspace: KeyBinding {
key: Key::Backspace, key: Key::Backspace,
alt: Some(Key::Ctrl('h')), alt: Some(Key::Ctrl('h')),
+2
View File
@@ -13,6 +13,8 @@ mod test {
#[case(DEFAULT_KEYBINDINGS.down, Key::Down, Some(Key::Char('j')), "down")] #[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.left, Key::Left, Some(Key::Char('h')), "left")]
#[case(DEFAULT_KEYBINDINGS.right, Key::Right, Some(Key::Char('l')), "right")] #[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.backspace, Key::Backspace, Some(Key::Ctrl('h')), "backspace")]
#[case(DEFAULT_KEYBINDINGS.next_servarr, Key::Tab, None, "next servarr")] #[case(DEFAULT_KEYBINDINGS.next_servarr, Key::Tab, None, "next servarr")]
#[case(DEFAULT_KEYBINDINGS.previous_servarr, Key::BackTab, None, "previous servarr")] #[case(DEFAULT_KEYBINDINGS.previous_servarr, Key::BackTab, None, "previous servarr")]
+1 -1
View File
@@ -52,7 +52,7 @@ impl App<'_> {
) -> Self { ) -> Self {
let mut server_tabs = Vec::new(); let mut server_tabs = Vec::new();
let help = format!( let help = format!(
"<↑↓> scroll | ←→ change tab | {} ", "<↑↓> scroll | <C-u/d> page up/down | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES) build_context_clue_string(&SERVARR_CONTEXT_CLUES)
); );
+12
View File
@@ -13,6 +13,8 @@ pub enum Key {
Down, Down,
Left, Left,
Right, Right,
PgDown,
PgUp,
Enter, Enter,
Esc, Esc,
Backspace, Backspace,
@@ -35,6 +37,8 @@ impl Display for Key {
Key::Down => write!(f, "<↓>"), Key::Down => write!(f, "<↓>"),
Key::Left => write!(f, "<←>"), Key::Left => write!(f, "<←>"),
Key::Right => write!(f, "<→>"), Key::Right => write!(f, "<→>"),
Key::PgDown => write!(f, "<C-d>"),
Key::PgUp => write!(f, "<C-u>"),
Key::Enter => write!(f, "<enter>"), Key::Enter => write!(f, "<enter>"),
Key::Esc => write!(f, "<esc>"), Key::Esc => write!(f, "<esc>"),
Key::Backspace => write!(f, "<backspace>"), Key::Backspace => write!(f, "<backspace>"),
@@ -66,6 +70,14 @@ impl From<KeyEvent> for Key {
code: KeyCode::Right, code: KeyCode::Right,
.. ..
} => Key::Right, } => Key::Right,
KeyEvent {
code: KeyCode::PageDown,
..
} => Key::PgDown,
KeyEvent {
code: KeyCode::PageUp,
..
} => Key::PgUp,
KeyEvent { KeyEvent {
code: KeyCode::Backspace, code: KeyCode::Backspace,
.. ..
+12
View File
@@ -11,6 +11,8 @@ mod tests {
#[case(Key::Down, "")] #[case(Key::Down, "")]
#[case(Key::Left, "")] #[case(Key::Left, "")]
#[case(Key::Right, "")] #[case(Key::Right, "")]
#[case(Key::PgDown, "C-d")]
#[case(Key::PgUp, "C-u")]
#[case(Key::Enter, "enter")] #[case(Key::Enter, "enter")]
#[case(Key::Esc, "esc")] #[case(Key::Esc, "esc")]
#[case(Key::Backspace, "backspace")] #[case(Key::Backspace, "backspace")]
@@ -45,6 +47,16 @@ mod tests {
assert_eq!(Key::from(KeyEvent::from(KeyCode::Right)), Key::Right); 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] #[test]
fn test_key_from_backspace() { fn test_key_from_backspace() {
assert_eq!( assert_eq!(
+26
View File
@@ -44,6 +44,8 @@ macro_rules! handle_table_events {
match $self.key { match $self.key {
_ if $crate::matches_key!(up, $self.key, $self.ignore_special_keys()) => $self.[<handle_ $name _table_scroll_up>](config), _ if $crate::matches_key!(up, $self.key, $self.ignore_special_keys()) => $self.[<handle_ $name _table_scroll_up>](config),
_ if $crate::matches_key!(down, $self.key, $self.ignore_special_keys()) => $self.[<handle_ $name _table_scroll_down>](config), _ if $crate::matches_key!(down, $self.key, $self.ignore_special_keys()) => $self.[<handle_ $name _table_scroll_down>](config),
_ if $crate::matches_key!(pg_up, $self.key, $self.ignore_special_keys()) => $self.[<handle_ $name _table_page_up>](config),
_ if $crate::matches_key!(pg_down, $self.key, $self.ignore_special_keys()) => $self.[<handle_ $name _table_page_down>](config),
_ if $crate::matches_key!(home, $self.key) => $self.[<handle_ $name _table_home>](config), _ if $crate::matches_key!(home, $self.key) => $self.[<handle_ $name _table_home>](config),
_ if $crate::matches_key!(end, $self.key) => $self.[<handle_ $name _table_end>](config), _ if $crate::matches_key!(end, $self.key) => $self.[<handle_ $name _table_end>](config),
_ if $crate::matches_key!(left, $self.key, $self.ignore_special_keys()) _ if $crate::matches_key!(left, $self.key, $self.ignore_special_keys())
@@ -116,6 +118,30 @@ macro_rules! handle_table_events {
} }
} }
fn [<handle_ $name _table_page_down>](&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 [<handle_ $name _table_page_up>](&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 [<handle_ $name _table_home>](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { fn [<handle_ $name _table_home>](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool {
use $crate::models::Scrollable; use $crate::models::Scrollable;
+42
View File
@@ -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 { mod test_handle_left_right_action {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::sync::atomic::Ordering::SeqCst; use std::sync::atomic::Ordering::SeqCst;
+5
View File
@@ -49,6 +49,11 @@ pub trait Scrollable {
fn scroll_to_bottom(&mut self); fn scroll_to_bottom(&mut self);
} }
pub trait Paginated {
fn page_down(&mut self);
fn page_up(&mut self);
}
#[derive(Default)] #[derive(Default)]
pub struct ScrollableText { pub struct ScrollableText {
pub items: Vec<String>, pub items: Vec<String>,
+76 -1
View File
@@ -1,5 +1,7 @@
use crate::models::stateful_list::StatefulList; 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 ratatui::widgets::TableState;
use std::cmp::Ordering; use std::cmp::Ordering;
use std::fmt::Debug; use std::fmt::Debug;
@@ -151,6 +153,79 @@ where
} }
} }
impl<T> Paginated for StatefulTable<T>
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<T> StatefulTable<T> impl<T> StatefulTable<T>
where where
T: Clone + PartialEq + Eq + Debug + Default, T: Clone + PartialEq + Eq + Debug + Default,
+170 -1
View File
@@ -1,9 +1,10 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::models::stateful_table::{SortOption, StatefulTable}; 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 pretty_assertions::{assert_eq, assert_str_eq};
use ratatui::widgets::TableState; use ratatui::widgets::TableState;
use std::iter;
#[test] #[test]
fn test_stateful_table_scrolling_on_empty_table_performs_no_op() { 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<String> = 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<String> = 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] #[test]
fn test_stateful_table_set_items() { fn test_stateful_table_set_items() {
let items_vec = vec!["Test 1", "Test 2", "Test 3"]; let items_vec = vec!["Test 1", "Test 2", "Test 3"];