feat(readarr): scroll and dismiss the edition details popup

Add EditionDetailsHandler, the single block popup opened from the editions
table. It owns the arrow, Home and End scrolling over its ScrollableText body,
and the Esc that pops the popup and clears the edition details modal.

The handler is nested under BookDetailsHandler, which claims the block ahead
of its own tables. It never sees the keypress that opens it, so it performs no
build, and it dispatches no network events: the popup is assembled from an
already loaded row.

Scrolling is implemented rather than stubbed. The body is mostly the edition
overview, which routinely overflows the popup, so empty scroll methods would
leave the arrow keys dead on the longest text in the view.
This commit is contained in:
Dark-Alex-17 committed 2026-09-03 11:51:20 -06:00
1 parent 14deed6b81
commit 751ff6ecf5
6 files changed
+577 -4

No files matched your search

@@ -13,6 +13,7 @@ use crate::network::readarr_network::ReadarrEvent;
use indoc::formatdoc; use indoc::formatdoc;
use super::author_details_handler::releases_sorting_options; use super::author_details_handler::releases_sorting_options;
use super::edition_details_handler::EditionDetailsHandler;
#[cfg(test)] #[cfg(test)]
#[path = "book_details_handler_tests.rs"] #[path = "book_details_handler_tests.rs"]
@@ -133,6 +134,16 @@ impl BookDetailsHandler<'_, '_> {
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BookDetailsHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BookDetailsHandler<'a, 'b> {
fn handle(&mut self) { fn handle(&mut self) {
if EditionDetailsHandler::accepts(self.active_readarr_block) {
return EditionDetailsHandler::new(
self.key,
self.app,
self.active_readarr_block,
self._context,
)
.handle();
}
let editions_table_handling_config = let editions_table_handling_config =
TableHandlingConfig::new(ActiveReadarrBlock::BookDetails.into()) TableHandlingConfig::new(ActiveReadarrBlock::BookDetails.into())
.searching_block(ActiveReadarrBlock::SearchEditions.into()) .searching_block(ActiveReadarrBlock::SearchEditions.into())
@@ -197,7 +208,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BookDetailsHandler<
} }
fn accepts(active_block: ActiveReadarrBlock) -> bool { fn accepts(active_block: ActiveReadarrBlock) -> bool {
BOOK_DETAILS_BLOCKS.contains(&active_block) EditionDetailsHandler::accepts(active_block) || BOOK_DETAILS_BLOCKS.contains(&active_block)
} }
fn ignore_special_keys(&self) -> bool { fn ignore_special_keys(&self) -> bool {
@@ -13,9 +13,10 @@ mod tests {
}; };
use crate::models::servarr_data::readarr::modals::BookDetailsModal; use crate::models::servarr_data::readarr::modals::BookDetailsModal;
use crate::models::servarr_data::readarr::readarr_data::{ use crate::models::servarr_data::readarr::readarr_data::{
ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS,
}; };
use crate::models::servarr_models::{Quality, QualityWrapper}; use crate::models::servarr_models::{Quality, QualityWrapper};
use crate::test_handler_delegation;
mod test_handle_scroll_up_and_down { mod test_handle_scroll_up_and_down {
use super::*; use super::*;
@@ -866,8 +867,12 @@ mod tests {
#[test] #[test]
fn test_book_details_handler_accepts() { fn test_book_details_handler_accepts() {
let mut book_details_handler_blocks = Vec::new();
book_details_handler_blocks.extend(BOOK_DETAILS_BLOCKS);
book_details_handler_blocks.extend(EDITION_DETAILS_BLOCKS);
ActiveReadarrBlock::iter().for_each(|active_readarr_block| { ActiveReadarrBlock::iter().for_each(|active_readarr_block| {
if BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) { if book_details_handler_blocks.contains(&active_readarr_block) {
assert!( assert!(
BookDetailsHandler::accepts(active_readarr_block), BookDetailsHandler::accepts(active_readarr_block),
"{active_readarr_block} is not accepted by the BookDetailsHandler" "{active_readarr_block} is not accepted by the BookDetailsHandler"
@@ -881,6 +886,15 @@ mod tests {
}); });
} }
#[test]
fn test_delegates_edition_details_blocks_to_edition_details_handler() {
test_handler_delegation!(
BookDetailsHandler,
ActiveReadarrBlock::BookDetails,
ActiveReadarrBlock::BookEditionDetails
);
}
#[test] #[test]
fn test_book_details_handler_is_not_ready_when_loading() { fn test_book_details_handler_is_not_ready_when_loading() {
let mut app = App::test_default(); let mut app = App::test_default();
@@ -0,0 +1,125 @@
use crate::app::App;
use crate::event::Key;
use crate::handlers::KeyEventHandler;
use crate::matches_key;
use crate::models::servarr_data::readarr::readarr_data::{
ActiveReadarrBlock, EDITION_DETAILS_BLOCKS,
};
use crate::models::{Route, Scrollable, ScrollableText};
#[cfg(test)]
#[path = "edition_details_handler_tests.rs"]
mod edition_details_handler_tests;
pub(super) struct EditionDetailsHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
active_readarr_block: ActiveReadarrBlock,
_context: Option<ActiveReadarrBlock>,
}
impl EditionDetailsHandler<'_, '_> {
fn edition_details(&mut self) -> &mut ScrollableText {
&mut self
.app
.data
.readarr_data
.book_details_modal
.as_mut()
.expect("Book details modal is undefined")
.edition_details_modal
.as_mut()
.expect("Edition details modal is undefined")
.edition_details
}
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for EditionDetailsHandler<'a, 'b> {
fn accepts(active_block: ActiveReadarrBlock) -> bool {
EDITION_DETAILS_BLOCKS.contains(&active_block)
}
fn ignore_special_keys(&self) -> bool {
self.app.ignore_special_keys_for_textbox_input
}
fn new(
key: Key,
app: &'a mut App<'b>,
active_block: ActiveReadarrBlock,
_context: Option<ActiveReadarrBlock>,
) -> EditionDetailsHandler<'a, 'b> {
EditionDetailsHandler {
key,
app,
active_readarr_block: active_block,
_context,
}
}
fn get_key(&self) -> Key {
self.key
}
fn is_ready(&self) -> bool {
if self.app.is_loading {
return false;
}
let Some(book_details_modal) = &self.app.data.readarr_data.book_details_modal else {
return false;
};
book_details_modal.edition_details_modal.is_some()
}
fn handle_scroll_up(&mut self) {
self.edition_details().scroll_up();
}
fn handle_scroll_down(&mut self) {
self.edition_details().scroll_down();
}
fn handle_home(&mut self) {
self.edition_details().scroll_to_top();
}
fn handle_end(&mut self) {
self.edition_details().scroll_to_bottom();
}
fn handle_delete(&mut self) {}
fn handle_left_right_action(&mut self) {}
fn handle_submit(&mut self) {}
fn handle_esc(&mut self) {
self.app.pop_navigation_stack();
self
.app
.data
.readarr_data
.book_details_modal
.as_mut()
.expect("Book details modal is undefined")
.edition_details_modal = None;
}
fn handle_char_key_event(&mut self) {
if matches_key!(refresh, self.key) {
self
.app
.pop_and_push_navigation_stack(self.active_readarr_block.into());
}
}
fn app_mut(&mut self) -> &mut App<'b> {
self.app
}
fn current_route(&self) -> Route {
self.app.get_current_route()
}
}
@@ -0,0 +1,420 @@
#[cfg(test)]
mod tests {
use strum::IntoEnumIterator;
use crate::app::App;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::handlers::KeyEventHandler;
use crate::handlers::readarr_handlers::library::edition_details_handler::EditionDetailsHandler;
use crate::models::ScrollableText;
use crate::models::servarr_data::readarr::modals::{BookDetailsModal, EditionDetailsModal};
use crate::models::servarr_data::readarr::readarr_data::{
ActiveReadarrBlock, EDITION_DETAILS_BLOCKS,
};
mod test_handle_scroll_up_and_down {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_edition_details_scroll_down() {
let mut app = app_with_edition_details();
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.down.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_eq!(edition_details_offset(&app), 1);
}
#[test]
fn test_edition_details_scroll_up() {
let mut app = app_with_edition_details();
set_offset(&mut app, 2);
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_eq!(edition_details_offset(&app), 1);
}
#[test]
fn test_edition_details_scroll_down_no_op_when_not_ready() {
let mut app = app_with_edition_details();
app.is_loading = true;
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.down.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_eq!(edition_details_offset(&app), 0);
}
#[test]
fn test_edition_details_scroll_up_no_op_when_not_ready() {
let mut app = app_with_edition_details();
set_offset(&mut app, 2);
app.is_loading = true;
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_eq!(edition_details_offset(&app), 2);
}
#[test]
fn test_edition_details_scrolls_past_the_first_screen() {
let mut app = app_with_edition_details();
for _ in 0..4 {
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.down.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
}
assert_eq!(edition_details_offset(&app), 4);
}
}
mod test_handle_home_end {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_edition_details_end() {
let mut app = app_with_edition_details();
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.end.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_eq!(edition_details_offset(&app), 5);
}
#[test]
fn test_edition_details_home() {
let mut app = app_with_edition_details();
set_offset(&mut app, 4);
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.home.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_eq!(edition_details_offset(&app), 0);
}
#[test]
fn test_edition_details_end_no_op_when_not_ready() {
let mut app = app_with_edition_details();
app.is_loading = true;
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.end.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_eq!(edition_details_offset(&app), 0);
}
#[test]
fn test_edition_details_home_no_op_when_not_ready() {
let mut app = app_with_edition_details();
set_offset(&mut app, 4);
app.is_loading = true;
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.home.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_eq!(edition_details_offset(&app), 4);
}
}
mod test_handle_esc {
use super::*;
use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped};
#[test]
fn test_edition_details_esc_tears_down_the_edition_details_modal() {
let mut app = app_with_edition_details();
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into());
assert_modal_absent!(
app
.data
.readarr_data
.book_details_modal
.as_ref()
.unwrap()
.edition_details_modal
);
assert_modal_present!(app.data.readarr_data.book_details_modal);
}
#[test]
fn test_edition_details_esc_tears_down_the_modal_even_when_loading() {
let mut app = app_with_edition_details();
app.is_loading = true;
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into());
assert_modal_absent!(
app
.data
.readarr_data
.book_details_modal
.as_ref()
.unwrap()
.edition_details_modal
);
}
}
mod test_handle_key_char {
use super::*;
use crate::assert_navigation_pushed;
#[test]
fn test_refresh_key() {
let mut app = app_with_edition_details();
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.refresh.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into());
}
#[test]
fn test_refresh_key_no_op_when_not_ready() {
let mut app = app_with_edition_details();
app.is_loading = true;
app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into());
EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.refresh.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveReadarrBlock::BookHistory.into());
}
}
#[test]
fn test_edition_details_handler_accepts() {
ActiveReadarrBlock::iter().for_each(|active_readarr_block| {
if EDITION_DETAILS_BLOCKS.contains(&active_readarr_block) {
assert!(
EditionDetailsHandler::accepts(active_readarr_block),
"{active_readarr_block} is not accepted by the EditionDetailsHandler"
);
} else {
assert!(
!EditionDetailsHandler::accepts(active_readarr_block),
"{active_readarr_block} is wrongly accepted by the EditionDetailsHandler"
);
}
});
}
#[test]
fn test_edition_details_handler_dispatches_no_network_events() {
let mut app = app_with_edition_details();
for key in [
DEFAULT_KEYBINDINGS.up.key,
DEFAULT_KEYBINDINGS.down.key,
DEFAULT_KEYBINDINGS.home.key,
DEFAULT_KEYBINDINGS.end.key,
DEFAULT_KEYBINDINGS.delete.key,
DEFAULT_KEYBINDINGS.left.key,
DEFAULT_KEYBINDINGS.right.key,
DEFAULT_KEYBINDINGS.submit.key,
DEFAULT_KEYBINDINGS.refresh.key,
] {
EditionDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookEditionDetails, None)
.handle();
assert_none!(app.data.readarr_data.prompt_confirm_action);
assert!(!app.data.readarr_data.prompt_confirm);
}
}
#[test]
fn test_edition_details_handler_is_not_ready_when_loading() {
let mut app = app_with_edition_details();
app.is_loading = true;
let handler = EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_edition_details_handler_is_not_ready_when_the_book_details_modal_is_absent() {
let mut app = App::test_default();
let handler = EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_edition_details_handler_is_not_ready_when_the_edition_details_modal_is_absent() {
let mut app = App::test_default();
app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default());
let handler = EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_edition_details_handler_is_ready() {
let mut app = app_with_edition_details();
let handler = EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
);
assert!(handler.is_ready());
}
#[test]
fn test_edition_details_handler_ignores_special_keys_for_textbox_input() {
let mut app = app_with_edition_details();
app.ignore_special_keys_for_textbox_input = true;
let handler = EditionDetailsHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveReadarrBlock::BookEditionDetails,
None,
);
assert!(handler.ignore_special_keys());
}
fn app_with_edition_details() -> App<'static> {
let mut app = App::test_default();
let book_details_modal = BookDetailsModal {
edition_details_modal: Some(EditionDetailsModal {
edition_details: ScrollableText::with_string(
"Title: Test Edition\nFormat: Paperback\nLanguage: eng\nPublisher: Test\nPage Count: 662\nOverview: A long overview"
.to_owned(),
),
}),
..BookDetailsModal::default()
};
app.data.readarr_data.book_details_modal = Some(book_details_modal);
app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into());
app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into());
app
}
fn edition_details_offset(app: &App<'_>) -> u16 {
app
.data
.readarr_data
.book_details_modal
.as_ref()
.unwrap()
.edition_details_modal
.as_ref()
.unwrap()
.edition_details
.offset
}
fn set_offset(app: &mut App<'_>, offset: u16) {
app
.data
.readarr_data
.book_details_modal
.as_mut()
.unwrap()
.edition_details_modal
.as_mut()
.unwrap()
.edition_details
.offset = offset;
}
}
@@ -14,7 +14,8 @@ mod tests {
use crate::models::HorizontallyScrollableText; use crate::models::HorizontallyScrollableText;
use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus};
use crate::models::servarr_data::readarr::readarr_data::{ use crate::models::servarr_data::readarr::readarr_data::{
AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, LIBRARY_BLOCKS, AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS,
LIBRARY_BLOCKS,
}; };
use crate::test_handler_delegation; use crate::test_handler_delegation;
@@ -894,6 +895,7 @@ mod tests {
library_handler_blocks.extend(LIBRARY_BLOCKS); library_handler_blocks.extend(LIBRARY_BLOCKS);
library_handler_blocks.extend(AUTHOR_DETAILS_BLOCKS); library_handler_blocks.extend(AUTHOR_DETAILS_BLOCKS);
library_handler_blocks.extend(BOOK_DETAILS_BLOCKS); library_handler_blocks.extend(BOOK_DETAILS_BLOCKS);
library_handler_blocks.extend(EDITION_DETAILS_BLOCKS);
ActiveReadarrBlock::iter().for_each(|readarr_block| { ActiveReadarrBlock::iter().for_each(|readarr_block| {
if library_handler_blocks.contains(&readarr_block) { if library_handler_blocks.contains(&readarr_block) {
@@ -21,6 +21,7 @@ use crate::models::Route;
mod author_details_handler; mod author_details_handler;
mod book_details_handler; mod book_details_handler;
mod edition_details_handler;
pub(in crate::handlers::readarr_handlers) use { pub(in crate::handlers::readarr_handlers) use {
author_details_handler::AuthorDetailsHandler, book_details_handler::BookDetailsHandler, author_details_handler::AuthorDetailsHandler, book_details_handler::BookDetailsHandler,