Files
managarr/src/handlers/lidarr_handlers/library/artist_details_handler.rs
T
Dark-Alex-17 ca04498717 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.
2026-09-08 21:05:50 -06:00

478 lines
15 KiB
Rust

use super::artist_overview_handler::ArtistOverviewHandler;
use crate::app::App;
use crate::event::Key;
use crate::handlers::lidarr_handlers::history::history_sorting_options;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::matches_key;
use crate::models::lidarr_models::{Album, LidarrHistoryItem, LidarrRelease};
use crate::models::servarr_data::lidarr::lidarr_data::{
ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, DELETE_ALBUM_SELECTION_BLOCKS,
EDIT_ARTIST_SELECTION_BLOCKS,
};
use crate::models::servarr_data::lidarr::modals::ArtistOverviewModal;
use crate::models::servarr_models::ReleaseDownloadBody;
use crate::models::stateful_table::SortOption;
use crate::models::{BlockSelectionState, Route, ScrollableText};
use crate::network::lidarr_network::LidarrEvent;
use serde_json::Number;
#[cfg(test)]
#[path = "artist_details_handler_tests.rs"]
mod artist_details_handler_tests;
pub struct ArtistDetailsHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
active_lidarr_block: ActiveLidarrBlock,
_context: Option<ActiveLidarrBlock>,
}
impl ArtistDetailsHandler<'_, '_> {
fn extract_artist_id(&self) -> i64 {
self.app.data.lidarr_data.artists.current_selection().id
}
fn extract_album_id(&self) -> i64 {
self.app.data.lidarr_data.albums.current_selection().id
}
fn build_artist_overview_modal(&mut self) {
let overview = self
.app
.data
.lidarr_data
.artists
.current_selection()
.overview
.clone()
.unwrap_or_default();
self.app.data.lidarr_data.artist_overview_modal = Some(ArtistOverviewModal {
overview: ScrollableText::with_string(overview),
});
}
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler<'a, 'b> {
fn handle(&mut self) {
if ArtistOverviewHandler::accepts(self.active_lidarr_block) {
return ArtistOverviewHandler::new(
self.key,
self.app,
self.active_lidarr_block,
self._context,
)
.handle();
}
let albums_table_handling_config =
TableHandlingConfig::new(ActiveLidarrBlock::ArtistDetails.into())
.searching_block(ActiveLidarrBlock::SearchAlbums.into())
.search_error_block(ActiveLidarrBlock::SearchAlbumsError.into())
.search_field_fn(|album: &Album| &album.title.text);
let artist_history_table_handling_config =
TableHandlingConfig::new(ActiveLidarrBlock::ArtistHistory.into())
.sorting_block(ActiveLidarrBlock::ArtistHistorySortPrompt.into())
.sort_options(history_sorting_options())
.searching_block(ActiveLidarrBlock::SearchArtistHistory.into())
.search_error_block(ActiveLidarrBlock::SearchArtistHistoryError.into())
.search_field_fn(|history_item: &LidarrHistoryItem| &history_item.source_title.text)
.filtering_block(ActiveLidarrBlock::FilterArtistHistory.into())
.filter_error_block(ActiveLidarrBlock::FilterArtistHistoryError.into())
.filter_field_fn(|history_item: &LidarrHistoryItem| &history_item.source_title.text);
let artist_releases_table_handling_config =
TableHandlingConfig::new(ActiveLidarrBlock::ManualArtistSearch.into())
.sorting_block(ActiveLidarrBlock::ManualArtistSearchSortPrompt.into())
.sort_options(releases_sorting_options());
if !handle_table(
self,
|app| &mut app.data.lidarr_data.albums,
albums_table_handling_config,
) && !handle_table(
self,
|app| &mut app.data.lidarr_data.artist_history,
artist_history_table_handling_config,
) && !handle_table(
self,
|app| &mut app.data.lidarr_data.discography_releases,
artist_releases_table_handling_config,
) {
self.handle_key_event();
}
}
fn accepts(active_block: ActiveLidarrBlock) -> bool {
ArtistOverviewHandler::accepts(active_block) || ARTIST_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: ActiveLidarrBlock,
_context: Option<ActiveLidarrBlock>,
) -> ArtistDetailsHandler<'a, 'b> {
ArtistDetailsHandler {
key,
app,
active_lidarr_block: active_block,
_context,
}
}
fn get_key(&self) -> Key {
self.key
}
fn is_ready(&self) -> bool {
if self.app.is_loading {
return false;
}
match self.active_lidarr_block {
ActiveLidarrBlock::ArtistHistory => !self.app.data.lidarr_data.artist_history.is_empty(),
ActiveLidarrBlock::ManualArtistSearch => {
!self.app.data.lidarr_data.discography_releases.is_empty()
}
_ => true,
}
}
fn handle_scroll_up(&mut self) {}
fn handle_scroll_down(&mut self) {}
fn handle_home(&mut self) {}
fn handle_end(&mut self) {}
fn handle_delete(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::ArtistDetails {
self
.app
.push_navigation_stack(ActiveLidarrBlock::DeleteAlbumPrompt.into());
self.app.data.lidarr_data.selected_block =
BlockSelectionState::new(DELETE_ALBUM_SELECTION_BLOCKS);
}
}
fn handle_left_right_action(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::ArtistDetails
| ActiveLidarrBlock::ArtistHistory
| ActiveLidarrBlock::ManualArtistSearch => match self.key {
_ if matches_key!(left, self.key) => {
self.app.data.lidarr_data.artist_info_tabs.previous();
self.app.pop_and_push_navigation_stack(
self
.app
.data
.lidarr_data
.artist_info_tabs
.get_active_route(),
);
}
_ if matches_key!(right, self.key) => {
self.app.data.lidarr_data.artist_info_tabs.next();
self.app.pop_and_push_navigation_stack(
self
.app
.data
.lidarr_data
.artist_info_tabs
.get_active_route(),
);
}
_ => (),
},
ActiveLidarrBlock::UpdateAndScanArtistPrompt
| ActiveLidarrBlock::AutomaticallySearchArtistPrompt
| ActiveLidarrBlock::ManualArtistSearchConfirmPrompt => {
handle_prompt_toggle(self.app, self.key);
}
_ => (),
}
}
fn handle_submit(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::ArtistDetails if !self.app.data.lidarr_data.albums.is_empty() => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::AlbumDetails.into());
}
ActiveLidarrBlock::ArtistHistory if !self.app.data.lidarr_data.artist_history.is_empty() => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::ArtistHistoryDetails.into());
}
ActiveLidarrBlock::ManualArtistSearch => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::ManualArtistSearchConfirmPrompt.into());
}
ActiveLidarrBlock::ManualArtistSearchConfirmPrompt => {
if self.app.data.lidarr_data.prompt_confirm {
let LidarrRelease {
guid, indexer_id, ..
} = self
.app
.data
.lidarr_data
.discography_releases
.current_selection()
.clone();
let params = ReleaseDownloadBody { guid, indexer_id };
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::DownloadRelease(params));
}
self.app.pop_navigation_stack();
}
ActiveLidarrBlock::AutomaticallySearchArtistPrompt => {
if self.app.data.lidarr_data.prompt_confirm {
self.app.data.lidarr_data.prompt_confirm_action = Some(
LidarrEvent::TriggerAutomaticArtistSearch(self.extract_artist_id()),
);
}
self.app.pop_navigation_stack();
}
ActiveLidarrBlock::UpdateAndScanArtistPrompt => {
if self.app.data.lidarr_data.prompt_confirm {
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::UpdateAndScanArtist(self.extract_artist_id()));
}
self.app.pop_navigation_stack();
}
_ => (),
}
}
fn handle_esc(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::UpdateAndScanArtistPrompt
| ActiveLidarrBlock::AutomaticallySearchArtistPrompt
| ActiveLidarrBlock::ManualArtistSearchConfirmPrompt => {
self.app.pop_navigation_stack();
self.app.data.lidarr_data.prompt_confirm = false;
}
ActiveLidarrBlock::ArtistHistoryDetails => {
self.app.pop_navigation_stack();
}
ActiveLidarrBlock::ArtistHistory => {
if self
.app
.data
.lidarr_data
.artist_history
.filtered_items
.is_some()
{
self.app.data.lidarr_data.artist_history.reset_filter();
} else {
self.app.pop_navigation_stack();
self.app.data.lidarr_data.reset_artist_info_tabs();
}
}
ActiveLidarrBlock::ArtistDetails | ActiveLidarrBlock::ManualArtistSearch => {
self.app.pop_navigation_stack();
self.app.data.lidarr_data.reset_artist_info_tabs();
}
_ => (),
}
}
fn handle_char_key_event(&mut self) {
let key = self.key;
match self.active_lidarr_block {
ActiveLidarrBlock::ArtistDetails => match self.key {
_ if matches_key!(refresh, key) => self
.app
.pop_and_push_navigation_stack(self.active_lidarr_block.into()),
_ if matches_key!(auto_search, key) => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::AutomaticallySearchArtistPrompt.into());
}
_ if matches_key!(update, key) => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::UpdateAndScanArtistPrompt.into());
}
_ if matches_key!(edit, key) => {
self.app.push_navigation_stack(
(
ActiveLidarrBlock::EditArtistPrompt,
Some(self.active_lidarr_block),
)
.into(),
);
self.app.data.lidarr_data.edit_artist_modal = Some((&self.app.data.lidarr_data).into());
self.app.data.lidarr_data.selected_block =
BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS);
}
_ if matches_key!(view, key) => {
self.build_artist_overview_modal();
self
.app
.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into());
}
_ if matches_key!(toggle_monitoring, key)
&& !self.app.data.lidarr_data.albums.is_empty() =>
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::ToggleAlbumMonitoring(self.extract_album_id()));
self
.app
.pop_and_push_navigation_stack(self.active_lidarr_block.into());
}
_ => (),
},
ActiveLidarrBlock::ArtistHistory | ActiveLidarrBlock::ManualArtistSearch => match self.key {
_ if matches_key!(refresh, key) => self
.app
.pop_and_push_navigation_stack(self.active_lidarr_block.into()),
_ if matches_key!(auto_search, key) => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::AutomaticallySearchArtistPrompt.into());
}
_ if matches_key!(edit, key) => {
self.app.push_navigation_stack(
(
ActiveLidarrBlock::EditArtistPrompt,
Some(self.active_lidarr_block),
)
.into(),
);
self.app.data.lidarr_data.edit_artist_modal = Some((&self.app.data.lidarr_data).into());
self.app.data.lidarr_data.selected_block =
BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS);
}
_ if matches_key!(update, key) => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::UpdateAndScanArtistPrompt.into());
}
_ => (),
},
ActiveLidarrBlock::AutomaticallySearchArtistPrompt => {
if matches_key!(confirm, key) {
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = Some(
LidarrEvent::TriggerAutomaticArtistSearch(self.extract_artist_id()),
);
self.app.pop_navigation_stack();
}
}
ActiveLidarrBlock::UpdateAndScanArtistPrompt => {
if matches_key!(confirm, key) {
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::UpdateAndScanArtist(self.extract_artist_id()));
self.app.pop_navigation_stack();
}
}
ActiveLidarrBlock::ManualArtistSearchConfirmPrompt => {
if matches_key!(confirm, key) {
self.app.data.lidarr_data.prompt_confirm = true;
let LidarrRelease {
guid, indexer_id, ..
} = self
.app
.data
.lidarr_data
.discography_releases
.current_selection()
.clone();
let params = ReleaseDownloadBody { guid, indexer_id };
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::DownloadRelease(params));
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
fn app_mut(&mut self) -> &mut App<'b> {
self.app
}
fn current_route(&self) -> Route {
self.app.get_current_route()
}
}
fn releases_sorting_options() -> Vec<SortOption<LidarrRelease>> {
vec![
SortOption {
name: "Source",
cmp_fn: Some(|a, b| a.protocol.cmp(&b.protocol)),
},
SortOption {
name: "Age",
cmp_fn: Some(|a, b| a.age.cmp(&b.age)),
},
SortOption {
name: "Rejected",
cmp_fn: Some(|a, b| a.rejected.cmp(&b.rejected)),
},
SortOption {
name: "Title",
cmp_fn: Some(|a, b| {
a.title
.text
.to_lowercase()
.cmp(&b.title.text.to_lowercase())
}),
},
SortOption {
name: "Indexer",
cmp_fn: Some(|a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase())),
},
SortOption {
name: "Size",
cmp_fn: Some(|a, b| a.size.cmp(&b.size)),
},
SortOption {
name: "Peers",
cmp_fn: Some(|a, b| {
let default_number = Number::from(i64::MAX);
let seeder_a = a
.seeders
.as_ref()
.unwrap_or(&default_number)
.as_u64()
.unwrap();
let seeder_b = b
.seeders
.as_ref()
.unwrap_or(&default_number)
.as_u64()
.unwrap();
seeder_a.cmp(&seeder_b)
}),
},
SortOption {
name: "Quality",
cmp_fn: Some(|a, b| a.quality.cmp(&b.quality)),
},
]
}