feat(lidarr): add a scrollable overview popup to artist details

An artist's overview renders as one line of a description box that does
not scroll, so a long overview overflows with no way to read the rest.
Pressing v on the artist details Albums tab now opens a popup holding
the full overview.

The popup owns its own ScrollableText, built fresh from the artist's
overview when it opens and torn down on Esc so a reopened popup starts
at the top. Scrolling mutates only that owned state. The background
paragraph renders the Artist record's overview, which carries no scroll
offset, so it cannot scroll along with the popup.

Cite the existing view keybinding from the artist details context clues
and give the popup its own clue set so it does not inherit the hints of
the view underneath.

Enrich the shared artist fixture to a multi-paragraph overview, and the
blocklist response fixtures that embed the same artist, so a scrolled
popup is distinguishable from an unscrolled one.
This commit is contained in:
Dark-Alex-17 committed 2026-09-08 21:05:50 -06:00
1 parent 27c458307f
commit ca04498717
59 files changed
+1521 -134

No files matched your search

@@ -15,9 +15,10 @@ mod tests {
use crate::models::HorizontallyScrollableText;
use crate::models::lidarr_models::{LidarrHistoryItem, LidarrRelease};
use crate::models::servarr_data::lidarr::lidarr_data::{
ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock,
ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock,
};
use crate::models::servarr_models::{Quality, QualityWrapper};
use crate::test_handler_delegation;
mod test_handle_delete {
use super::*;
@@ -450,6 +451,7 @@ mod tests {
use crate::assert_navigation_pushed;
use crate::handlers::KeyEventHandler;
use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler;
use crate::handlers::lidarr_handlers::library::artist_overview_handler::ArtistOverviewHandler;
use crate::models::lidarr_models::Artist;
use crate::models::servarr_data::lidarr::lidarr_data::{
ActiveLidarrBlock, EDIT_ARTIST_SELECTION_BLOCKS,
@@ -458,7 +460,7 @@ mod tests {
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::torrent_release;
use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped};
use pretty_assertions::assert_eq;
use pretty_assertions::{assert_eq, assert_str_eq};
use rstest::rstest;
#[rstest]
@@ -524,6 +526,161 @@ mod tests {
assert_modal_absent!(app.data.lidarr_data.edit_artist_modal);
}
#[test]
fn test_artist_details_view_key_opens_the_artist_overview() {
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.artist_overview_modal = None;
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.view.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::ArtistOverview.into());
assert_modal_present!(app.data.lidarr_data.artist_overview_modal);
}
#[test]
fn test_artist_details_view_key_builds_the_overview_from_the_selected_artist() {
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.artist_overview_modal = None;
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.view.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
)
.handle();
let overview = &app
.data
.lidarr_data
.artist_overview_modal
.as_ref()
.unwrap()
.overview;
assert_eq!(overview.offset, 0);
assert_eq!(
overview.get_text(),
app
.data
.lidarr_data
.artists
.current_selection()
.overview
.clone()
.unwrap()
);
}
#[test]
fn test_artist_details_view_key_for_an_artist_with_no_overview() {
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.artist_overview_modal = None;
let mut artist = app.data.lidarr_data.artists.current_selection().clone();
artist.overview = None;
app.data.lidarr_data.artists.set_items(vec![artist]);
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.view.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::ArtistOverview.into());
assert_str_eq!(
app
.data
.lidarr_data
.artist_overview_modal
.as_ref()
.unwrap()
.overview
.get_text(),
""
);
}
#[test]
fn test_artist_details_view_key_reopens_the_artist_overview_at_the_top() {
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.artist_overview_modal = None;
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.view.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
)
.handle();
ArtistOverviewHandler::new(
DEFAULT_KEYBINDINGS.end.key,
&mut app,
ActiveLidarrBlock::ArtistOverview,
None,
)
.handle();
ArtistOverviewHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::ArtistOverview,
None,
)
.handle();
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.view.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::ArtistOverview.into());
assert_eq!(
app
.data
.lidarr_data
.artist_overview_modal
.as_ref()
.unwrap()
.overview
.offset,
0
);
}
#[test]
fn test_artist_details_view_key_no_op_when_not_ready() {
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.artist_overview_modal = None;
app.is_loading = true;
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.view.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
)
.handle();
assert_eq!(
app.get_current_route(),
ActiveLidarrBlock::ArtistDetails.into()
);
assert_modal_absent!(app.data.lidarr_data.artist_overview_modal);
}
#[test]
fn test_artist_details_toggle_monitoring_key() {
let mut app = App::test_default_fully_populated();
@@ -813,8 +970,11 @@ mod tests {
#[test]
fn test_artist_details_handler_accepts() {
let mut artist_details_blocks = ARTIST_DETAILS_BLOCKS.to_vec();
artist_details_blocks.extend(ARTIST_OVERVIEW_BLOCKS);
ActiveLidarrBlock::iter().for_each(|active_lidarr_block| {
if ARTIST_DETAILS_BLOCKS.contains(&active_lidarr_block) {
if artist_details_blocks.contains(&active_lidarr_block) {
assert!(ArtistDetailsHandler::accepts(active_lidarr_block));
} else {
assert!(!ArtistDetailsHandler::accepts(active_lidarr_block));
@@ -822,6 +982,15 @@ mod tests {
});
}
#[test]
fn test_delegates_artist_overview_blocks_to_artist_overview_handler() {
test_handler_delegation!(
ArtistDetailsHandler,
ActiveLidarrBlock::ArtistDetails,
ActiveLidarrBlock::ArtistOverview
);
}
#[test]
fn test_extract_artist_id() {
let mut app = App::test_default_fully_populated();