feat(readarr): render the edition details popup

Draw the edition details popup over the book details view. The popup body is
the scrollable text the book details handler builds from the already loaded
edition row, so it needs no tabs and no network call.

The text styling is extracted into a named function so both branches are unit
tested, and the shared test fixture now holds the real field lines instead of a
placeholder, which exercises the scroll offset and the values that themselves
contain a colon.
This commit is contained in:
Dark-Alex-17 committed 2026-09-03 12:18:56 -06:00
1 parent b4c6994362
commit 280e691a94
16 files changed
+622 -6

No files matched your search

@@ -447,7 +447,20 @@ impl ReadarrData<'_> {
.set_items(vec!["Standard".to_owned()]);
let edition_details_modal = EditionDetailsModal {
edition_details: ScrollableText::with_string("Some details".to_owned()),
edition_details: ScrollableText::with_string(
"Title: Test Edition
Format: Hardcover
Language: English
Publisher: DAW Books
Page Count: 288
Release Date: 2023-05-20 00:00:00 UTC
ISBN13: 9780756404079
ASIN: B0043RSJ9S
Ratings: 4.5 (1000 votes)
Monitored: true
Overview: A test edition overview."
.to_owned(),
),
};
let mut book_details_modal = BookDetailsModal {
@@ -7,7 +7,7 @@ mod tests {
use crate::models::readarr_models::{Book, BookStatistics};
use crate::models::servarr_data::readarr::readarr_data::{
AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS,
AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS,
};
use crate::ui::DrawUi;
use crate::ui::readarr_ui::library::author_details_ui::{
@@ -19,6 +19,7 @@ mod tests {
fn test_author_details_ui_accepts() {
let mut author_details_blocks = AUTHOR_DETAILS_BLOCKS.to_vec();
author_details_blocks.extend(BOOK_DETAILS_BLOCKS);
author_details_blocks.extend(EDITION_DETAILS_BLOCKS);
ActiveReadarrBlock::iter().for_each(|active_readarr_block| {
if author_details_blocks.contains(&active_readarr_block) {
+7 -1
View File
@@ -2,6 +2,7 @@ use crate::app::App;
use crate::models::Route;
use crate::models::readarr_models::{BookFile, Edition, ReadarrHistoryItem, ReadarrRelease};
use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BOOK_DETAILS_BLOCKS};
use crate::ui::readarr_ui::library::edition_details_ui::EditionDetailsUi;
use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details;
use crate::ui::styles::{ManagarrStyle, secondary_style};
use crate::ui::utils::{
@@ -31,10 +32,11 @@ impl DrawUi for BookDetailsUi {
let Route::Readarr(active_readarr_block, _) = route else {
return false;
};
BOOK_DETAILS_BLOCKS.contains(&active_readarr_block)
EditionDetailsUi::accepts(route) || BOOK_DETAILS_BLOCKS.contains(&active_readarr_block)
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) {
let route = app.get_current_route();
if app.data.readarr_data.book_details_modal.is_some()
&& let Route::Readarr(active_readarr_block, _) = app.get_current_route()
{
@@ -98,6 +100,10 @@ impl DrawUi for BookDetailsUi {
};
draw_popup(f, app, draw_book_details_popup, Size::XLarge);
if EditionDetailsUi::accepts(route) {
EditionDetailsUi::draw(f, app, _area);
}
}
}
}
@@ -7,7 +7,7 @@ mod tests {
use crate::app::App;
use crate::models::readarr_models::Edition;
use crate::models::servarr_data::readarr::readarr_data::{
ActiveReadarrBlock, BOOK_DETAILS_BLOCKS,
ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS,
};
use crate::ui::DrawUi;
use crate::ui::readarr_ui::library::book_details_ui::{
@@ -18,8 +18,11 @@ mod tests {
#[test]
fn test_book_details_ui_accepts() {
let mut book_details_blocks = BOOK_DETAILS_BLOCKS.to_vec();
book_details_blocks.extend(EDITION_DETAILS_BLOCKS);
ActiveReadarrBlock::iter().for_each(|active_readarr_block| {
if BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) {
if book_details_blocks.contains(&active_readarr_block) {
assert!(BookDetailsUi::accepts(active_readarr_block.into()));
} else {
assert!(!BookDetailsUi::accepts(active_readarr_block.into()));
@@ -238,5 +241,17 @@ mod tests {
insta::assert_snapshot!(output);
}
#[test]
fn test_book_details_ui_renders_edition_details_over_book_details() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
BookDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
}
}
@@ -0,0 +1,102 @@
use crate::app::App;
use crate::models::Route;
use crate::models::readarr_models::Edition;
use crate::models::servarr_data::readarr::readarr_data::EDITION_DETAILS_BLOCKS;
use crate::ui::styles::{primary_style, unmonitored_style};
use crate::ui::utils::title_block_centered;
use crate::ui::widgets::loading_block::LoadingBlock;
use crate::ui::widgets::popup::Size;
use crate::ui::{DrawUi, draw_popup};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Style, Stylize};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Paragraph, Wrap};
#[cfg(test)]
#[path = "edition_details_ui_tests.rs"]
mod edition_details_ui_tests;
pub(super) struct EditionDetailsUi;
impl DrawUi for EditionDetailsUi {
fn accepts(route: Route) -> bool {
let Route::Readarr(active_readarr_block, _) = route else {
return false;
};
EDITION_DETAILS_BLOCKS.contains(&active_readarr_block)
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) {
if let Some(book_details_modal) = app.data.readarr_data.book_details_modal.as_ref()
&& book_details_modal.edition_details_modal.is_some()
{
let draw_edition_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| {
draw_edition_details(f, app, popup_area);
};
draw_popup(f, app, draw_edition_details_popup, Size::Large);
}
}
}
fn draw_edition_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
let block = title_block_centered("Edition Details");
match app.data.readarr_data.book_details_modal.as_ref() {
Some(book_details_modal) if !app.is_loading => {
if let Some(edition_details_modal) = book_details_modal.edition_details_modal.as_ref() {
let style = if book_details_modal.editions.is_empty() {
primary_style()
} else {
style_from_edition(book_details_modal.editions.current_selection())
};
let edition_details = &edition_details_modal.edition_details;
let text = Text::from(
edition_details
.items
.iter()
.filter(|line| !line.is_empty())
.map(|line| {
let split = line.split(':').collect::<Vec<&str>>();
let title = format!("{}:", split[0]);
Line::from(vec![
title.bold().style(style),
Span::styled(split[1..].join(":"), style),
])
})
.collect::<Vec<Line<'_>>>(),
);
let paragraph = Paragraph::new(text)
.block(block)
.wrap(Wrap { trim: false })
.scroll((edition_details.offset, 0));
f.render_widget(paragraph, area);
}
}
_ => f.render_widget(
LoadingBlock::new(
app.is_loading
|| app
.data
.readarr_data
.book_details_modal
.as_ref()
.is_none_or(|book_details_modal| book_details_modal.edition_details_modal.is_none()),
block,
),
area,
),
}
}
fn style_from_edition(edition: &Edition) -> Style {
if !edition.monitored {
return unmonitored_style();
}
primary_style()
}
@@ -0,0 +1,176 @@
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use strum::IntoEnumIterator;
use crate::app::App;
use crate::models::readarr_models::Edition;
use crate::models::servarr_data::readarr::readarr_data::{
ActiveReadarrBlock, EDITION_DETAILS_BLOCKS,
};
use crate::ui::DrawUi;
use crate::ui::readarr_ui::library::edition_details_ui::{EditionDetailsUi, style_from_edition};
use crate::ui::styles::{primary_style, unmonitored_style};
use crate::ui::ui_test_utils::test_utils::render_to_string_with_app;
#[test]
fn test_edition_details_ui_accepts() {
ActiveReadarrBlock::iter().for_each(|active_readarr_block| {
if EDITION_DETAILS_BLOCKS.contains(&active_readarr_block) {
assert!(EditionDetailsUi::accepts(active_readarr_block.into()));
} else {
assert!(!EditionDetailsUi::accepts(active_readarr_block.into()));
}
});
}
#[test]
fn test_style_from_edition_monitored() {
let edition = Edition {
monitored: true,
..Edition::default()
};
let style = style_from_edition(&edition);
assert_eq!(style, primary_style());
}
#[test]
fn test_style_from_edition_unmonitored() {
let edition = Edition {
monitored: false,
..Edition::default()
};
let style = style_from_edition(&edition);
assert_eq!(style, unmonitored_style());
}
mod snapshot_tests {
use crate::models::servarr_data::readarr::modals::EditionDetailsModal;
use crate::models::stateful_table::StatefulTable;
use crate::ui::ui_test_utils::test_utils::TerminalSize;
use super::*;
#[test]
fn test_edition_details_ui_renders() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
EditionDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_edition_details_ui_renders_loading() {
let mut app = App::test_default_fully_populated();
app.is_loading = true;
app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
EditionDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_edition_details_ui_renders_empty() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into());
app
.data
.readarr_data
.book_details_modal
.as_mut()
.unwrap()
.edition_details_modal = Some(EditionDetailsModal::default());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
EditionDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_edition_details_ui_renders_with_zero_editions() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into());
app
.data
.readarr_data
.book_details_modal
.as_mut()
.unwrap()
.editions = StatefulTable::default();
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
EditionDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_edition_details_ui_renders_scrolled() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into());
app
.data
.readarr_data
.book_details_modal
.as_mut()
.unwrap()
.edition_details_modal
.as_mut()
.unwrap()
.edition_details
.offset = 2;
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
EditionDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_edition_details_ui_renders_nothing_when_the_edition_details_modal_is_undefined() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into());
app
.data
.readarr_data
.book_details_modal
.as_mut()
.unwrap()
.edition_details_modal = None;
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
EditionDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_edition_details_ui_renders_nothing_when_the_book_details_modal_is_undefined() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into());
app.data.readarr_data.book_details_modal = None;
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
EditionDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
}
}
@@ -4,7 +4,8 @@ mod tests {
use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus};
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::ui::DrawUi;
use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style};
@@ -17,6 +18,7 @@ mod tests {
let mut blocks = LIBRARY_BLOCKS.to_vec();
blocks.extend(AUTHOR_DETAILS_BLOCKS);
blocks.extend(BOOK_DETAILS_BLOCKS);
blocks.extend(EDITION_DETAILS_BLOCKS);
for active_readarr_block in ActiveReadarrBlock::iter() {
if blocks.contains(&active_readarr_block) {
+1
View File
@@ -27,6 +27,7 @@ use crate::{
mod author_details_ui;
mod book_details_ui;
mod edition_details_ui;
#[cfg(test)]
#[path = "library_ui_tests.rs"]
@@ -0,0 +1,50 @@
---
source: src/ui/readarr_ui/library/book_details_ui_tests.rs
expression: output
---
╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Editions │ History │ File │ Manual Search │
│──────╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮─────│
│ For│Title: Test Edition │d │
│=> Har│Format: Hardcover │ │
│ │Language: English │ │
│ │Publisher: DAW Books │ │
│ │Page Count: 288 │ │
│ │Release Date: 2023-05-20 00:00:00 UTC │ │
│ │ISBN13: 9780756404079 │ │
│ │ASIN: B0043RSJ9S │ │
│ │Ratings: 4.5 (1000 votes) │ │
│ │Monitored: true │ │
│ │Overview: A test edition overview. │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,48 @@
---
source: src/ui/readarr_ui/library/edition_details_ui_tests.rs
expression: output
---
╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮
│Title: Test Edition │
│Format: Hardcover │
│Language: English │
│Publisher: DAW Books │
│Page Count: 288 │
│Release Date: 2023-05-20 00:00:00 UTC │
│ISBN13: 9780756404079 │
│ASIN: B0043RSJ9S │
│Ratings: 4.5 (1000 votes) │
│Monitored: true │
│Overview: A test edition overview. │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,48 @@
---
source: src/ui/readarr_ui/library/edition_details_ui_tests.rs
expression: output
---
╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,48 @@
---
source: src/ui/readarr_ui/library/edition_details_ui_tests.rs
expression: output
---
╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮
│ │
│ │
│ Loading ... │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,48 @@
---
source: src/ui/readarr_ui/library/edition_details_ui_tests.rs
expression: output
---
╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮
│Language: English │
│Publisher: DAW Books │
│Page Count: 288 │
│Release Date: 2023-05-20 00:00:00 UTC │
│ISBN13: 9780756404079 │
│ASIN: B0043RSJ9S │
│Ratings: 4.5 (1000 votes) │
│Monitored: true │
│Overview: A test edition overview. │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,48 @@
---
source: src/ui/readarr_ui/library/edition_details_ui_tests.rs
expression: output
---
╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮
│Title: Test Edition │
│Format: Hardcover │
│Language: English │
│Publisher: DAW Books │
│Page Count: 288 │
│Release Date: 2023-05-20 00:00:00 UTC │
│ISBN13: 9780756404079 │
│ASIN: B0043RSJ9S │
│Ratings: 4.5 (1000 votes) │
│Monitored: true │
│Overview: A test edition overview. │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯