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(),
route: ActiveSonarrBlock::default().into(),
help: format!(
"<↑↓> scroll | ←→ change tab | {} ",
"<↑↓> scroll | <C-u/d> 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 | <C-u/d> 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 | <C-u/d> 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 | <C-u/d> page up/down | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES)
),
contextual_help: None,
+12
View File
@@ -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')),
+2
View File
@@ -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")]
+1 -1
View File
@@ -52,7 +52,7 @@ impl App<'_> {
) -> Self {
let mut server_tabs = Vec::new();
let help = format!(
"<↑↓> scroll | ←→ change tab | {} ",
"<↑↓> scroll | <C-u/d> page up/down | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES)
);
+12
View File
@@ -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, "<C-d>"),
Key::PgUp => write!(f, "<C-u>"),
Key::Enter => write!(f, "<enter>"),
Key::Esc => write!(f, "<esc>"),
Key::Backspace => write!(f, "<backspace>"),
@@ -66,6 +70,14 @@ impl From<KeyEvent> for Key {
code: KeyCode::Right,
..
} => Key::Right,
KeyEvent {
code: KeyCode::PageDown,
..
} => Key::PgDown,
KeyEvent {
code: KeyCode::PageUp,
..
} => Key::PgUp,
KeyEvent {
code: KeyCode::Backspace,
..
+12
View File
@@ -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!(
+26
View File
@@ -44,6 +44,8 @@ macro_rules! handle_table_events {
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!(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!(end, $self.key) => $self.[<handle_ $name _table_end>](config),
_ 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 {
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 {
use pretty_assertions::assert_eq;
use std::sync::atomic::Ordering::SeqCst;
+5
View File
@@ -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<String>,
+76 -1
View File
@@ -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<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>
where
T: Clone + PartialEq + Eq + Debug + Default,
+170 -1
View File
@@ -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<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]
fn test_stateful_table_set_items() {
let items_vec = vec!["Test 1", "Test 2", "Test 3"];