feat(radarr): make the collection movie overview popup scrollable

A collection movie's overview renders in a popup that does not scroll,
so a long overview overflows with no way to read the rest. The popup now
scrolls.

The popup owns its own ScrollableText, built from the selected collection
movie'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 renders the Collection record's overview and the movies
table, neither of which carries a scroll offset, so they cannot scroll
along with the popup. The popup also gains the loading and absent
fallbacks the rest of the tree already has.

Give the collection movie fixture an overview distinct from its
collection's, and a multi-paragraph one, so the snapshots can tell the
two records apart and a scrolled popup is distinguishable from an
unscrolled one.
This commit is contained in:
Dark-Alex-17 committed 2026-09-08 22:07:22 -06:00
1 parent b3ce15a564
commit c9244b0459
24 files changed
+913 -66

No files matched your search

@@ -1,14 +1,16 @@
use super::movie_overview_handler::MovieOverviewHandler;
use crate::app::App;
use crate::event::Key;
use crate::handlers::KeyEventHandler;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::matches_key;
use crate::models::servarr_data::radarr::modals::MovieOverviewModal;
use crate::models::servarr_data::radarr::radarr_data::{
ADD_MOVIE_SELECTION_BLOCKS, ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS,
EDIT_COLLECTION_SELECTION_BLOCKS,
};
use crate::models::stateful_table::StatefulTable;
use crate::models::{BlockSelectionState, Route};
use crate::models::{BlockSelectionState, Route, ScrollableText};
#[cfg(test)]
#[path = "collection_details_handler_tests.rs"]
@@ -21,10 +23,35 @@ pub(super) struct CollectionDetailsHandler<'a, 'b> {
_context: Option<ActiveRadarrBlock>,
}
impl CollectionDetailsHandler<'_, '_> {}
impl CollectionDetailsHandler<'_, '_> {
fn build_movie_overview_modal(&mut self) {
let overview = self
.app
.data
.radarr_data
.collection_movies
.current_selection()
.overview
.clone();
self.app.data.radarr_data.movie_overview_modal = Some(MovieOverviewModal {
overview: ScrollableText::with_string(overview),
});
}
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHandler<'a, 'b> {
fn handle(&mut self) {
if MovieOverviewHandler::accepts(self.active_radarr_block) {
return MovieOverviewHandler::new(
self.key,
self.app,
self.active_radarr_block,
self._context,
)
.handle();
}
let collection_movies_table_handling_config =
TableHandlingConfig::new(ActiveRadarrBlock::CollectionDetails.into());
@@ -98,6 +125,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan
.iter()
.any(|movie| movie.tmdb_id == tmdb_id)
{
self.build_movie_overview_modal();
self
.app
.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into());
@@ -117,13 +145,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan
}
fn handle_esc(&mut self) {
match self.active_radarr_block {
ActiveRadarrBlock::CollectionDetails => {
self.app.data.radarr_data.collection_movies = StatefulTable::default();
self.app.pop_navigation_stack();
}
ActiveRadarrBlock::ViewMovieOverview => self.app.pop_navigation_stack(),
_ => (),
if self.active_radarr_block == ActiveRadarrBlock::CollectionDetails {
self.app.data.radarr_data.collection_movies = StatefulTable::default();
self.app.pop_navigation_stack();
}
}
@@ -11,15 +11,18 @@ mod tests {
use crate::handlers::KeyEventHandler;
use crate::handlers::radarr_handlers::collections::collection_details_handler::CollectionDetailsHandler;
use crate::models::radarr_models::CollectionMovie;
use crate::models::servarr_data::radarr::modals::MovieOverviewModal;
use crate::models::servarr_data::radarr::radarr_data::{
ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS,
};
use crate::test_handler_delegation;
mod test_handle_submit {
use crate::assert_navigation_pushed;
use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::collection_movie;
use crate::models::BlockSelectionState;
use crate::models::radarr_models::Movie;
use crate::models::servarr_data::radarr::radarr_data::ADD_MOVIE_SELECTION_BLOCKS;
use crate::{assert_modal_present, assert_navigation_pushed};
use bimap::BiMap;
use pretty_assertions::assert_eq;
@@ -143,12 +146,11 @@ mod tests {
.data
.radarr_data
.collection_movies
.set_items(vec![CollectionMovie::default()]);
app
.data
.radarr_data
.movies
.set_items(vec![Movie::default()]);
.set_items(vec![collection_movie()]);
app.data.radarr_data.movies.set_items(vec![Movie {
tmdb_id: collection_movie().tmdb_id,
..Movie::default()
}]);
CollectionDetailsHandler::new(
SUBMIT_KEY,
@@ -159,6 +161,63 @@ mod tests {
.handle();
assert_navigation_pushed!(app, ActiveRadarrBlock::ViewMovieOverview.into());
assert_modal_present!(app.data.radarr_data.movie_overview_modal);
let movie_overview_modal = app.data.radarr_data.movie_overview_modal.as_ref().unwrap();
assert_eq!(movie_overview_modal.overview.offset, 0);
assert_str_eq!(
movie_overview_modal.overview.get_text(),
collection_movie().overview
);
}
#[test]
fn test_collection_details_submit_reopens_the_movie_overview_at_the_top() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into());
app
.data
.radarr_data
.collection_movies
.set_items(vec![collection_movie()]);
app.data.radarr_data.movies.set_items(vec![Movie {
tmdb_id: collection_movie().tmdb_id,
..Movie::default()
}]);
CollectionDetailsHandler::new(
SUBMIT_KEY,
&mut app,
ActiveRadarrBlock::CollectionDetails,
None,
)
.handle();
CollectionDetailsHandler::new(
DEFAULT_KEYBINDINGS.end.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_eq!(overview_offset(&app), 5);
CollectionDetailsHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
CollectionDetailsHandler::new(
SUBMIT_KEY,
&mut app,
ActiveRadarrBlock::CollectionDetails,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveRadarrBlock::ViewMovieOverview.into());
assert_eq!(overview_offset(&app), 0);
}
}
@@ -198,6 +257,7 @@ mod tests {
let mut app = App::test_default();
app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into());
app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into());
app.data.radarr_data.movie_overview_modal = Some(MovieOverviewModal::default());
CollectionDetailsHandler::new(
ESC_KEY,
@@ -208,6 +268,7 @@ mod tests {
.handle();
assert_navigation_popped!(app, ActiveRadarrBlock::CollectionDetails.into());
assert_modal_absent!(app.data.radarr_data.movie_overview_modal);
}
}
@@ -277,6 +338,15 @@ mod tests {
});
}
#[test]
fn test_delegates_view_movie_overview_to_movie_overview_handler() {
test_handler_delegation!(
CollectionDetailsHandler,
ActiveRadarrBlock::CollectionDetails,
ActiveRadarrBlock::ViewMovieOverview
);
}
#[rstest]
fn test_collection_details_handler_ignore_special_keys(
#[values(true, false)] ignore_special_keys_for_textbox_input: bool,
@@ -345,4 +415,15 @@ mod tests {
assert!(handler.is_ready());
}
fn overview_offset(app: &App<'_>) -> u16 {
app
.data
.radarr_data
.movie_overview_modal
.as_ref()
.unwrap()
.overview
.offset
}
}
@@ -16,6 +16,7 @@ use crate::network::radarr_network::RadarrEvent;
mod collection_details_handler;
mod edit_collection_handler;
mod movie_overview_handler;
#[cfg(test)]
#[path = "collections_handler_tests.rs"]
@@ -0,0 +1,102 @@
use crate::app::App;
use crate::event::Key;
use crate::handlers::KeyEventHandler;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::{Route, Scrollable, ScrollableText};
#[cfg(test)]
#[path = "movie_overview_handler_tests.rs"]
mod movie_overview_handler_tests;
pub(super) struct MovieOverviewHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
_active_radarr_block: ActiveRadarrBlock,
_context: Option<ActiveRadarrBlock>,
}
impl MovieOverviewHandler<'_, '_> {
fn overview(&mut self) -> &mut ScrollableText {
&mut self
.app
.data
.radarr_data
.movie_overview_modal
.as_mut()
.expect("Movie overview modal is undefined")
.overview
}
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieOverviewHandler<'a, 'b> {
fn accepts(active_block: ActiveRadarrBlock) -> bool {
active_block == ActiveRadarrBlock::ViewMovieOverview
}
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: ActiveRadarrBlock,
_context: Option<ActiveRadarrBlock>,
) -> MovieOverviewHandler<'a, 'b> {
MovieOverviewHandler {
key,
app,
_active_radarr_block: active_block,
_context,
}
}
fn get_key(&self) -> Key {
self.key
}
fn is_ready(&self) -> bool {
if self.app.is_loading {
return false;
}
self.app.data.radarr_data.movie_overview_modal.is_some()
}
fn handle_scroll_up(&mut self) {
self.overview().scroll_up();
}
fn handle_scroll_down(&mut self) {
self.overview().scroll_down();
}
fn handle_home(&mut self) {
self.overview().scroll_to_top();
}
fn handle_end(&mut self) {
self.overview().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.radarr_data.movie_overview_modal = None;
}
fn handle_char_key_event(&mut self) {}
fn app_mut(&mut self) -> &mut App<'b> {
self.app
}
fn current_route(&self) -> Route {
self.app.get_current_route()
}
}
@@ -0,0 +1,345 @@
#[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::radarr_handlers::collections::movie_overview_handler::MovieOverviewHandler;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
mod test_handle_scroll_up_and_down {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_movie_overview_scroll_up() {
let mut app = app_with_movie_overview();
set_offset(&mut app, 2);
MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_eq!(overview_offset(&app), 1);
}
#[test]
fn test_movie_overview_scroll_up_no_op_when_not_ready() {
let mut app = app_with_movie_overview();
set_offset(&mut app, 2);
app.is_loading = true;
MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_eq!(overview_offset(&app), 2);
}
#[test]
fn test_movie_overview_scroll_down() {
let mut app = app_with_movie_overview();
MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.down.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_eq!(overview_offset(&app), 1);
}
#[test]
fn test_movie_overview_scroll_down_no_op_when_not_ready() {
let mut app = app_with_movie_overview();
app.is_loading = true;
MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.down.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_eq!(overview_offset(&app), 0);
}
}
mod test_handle_home_end {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_movie_overview_home() {
let mut app = app_with_movie_overview();
set_offset(&mut app, 3);
MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.home.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_eq!(overview_offset(&app), 0);
}
#[test]
fn test_movie_overview_end() {
let mut app = app_with_movie_overview();
MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.end.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_eq!(overview_offset(&app), 5);
}
#[test]
fn test_movie_overview_home_no_op_when_not_ready() {
let mut app = app_with_movie_overview();
set_offset(&mut app, 3);
app.is_loading = true;
MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.home.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_eq!(overview_offset(&app), 3);
}
#[test]
fn test_movie_overview_end_no_op_when_not_ready() {
let mut app = app_with_movie_overview();
app.is_loading = true;
MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.end.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_eq!(overview_offset(&app), 0);
}
}
mod test_handle_esc {
use super::*;
use crate::{assert_modal_absent, assert_navigation_popped};
#[test]
fn test_movie_overview_esc_tears_down_the_movie_overview_modal() {
let mut app = app_with_movie_overview();
MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_navigation_popped!(app, ActiveRadarrBlock::CollectionDetails.into());
assert_modal_absent!(app.data.radarr_data.movie_overview_modal);
}
#[test]
fn test_movie_overview_esc_tears_down_the_modal_even_when_loading() {
let mut app = app_with_movie_overview();
app.is_loading = true;
MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_navigation_popped!(app, ActiveRadarrBlock::CollectionDetails.into());
assert_modal_absent!(app.data.radarr_data.movie_overview_modal);
}
}
#[test]
fn test_scrolling_the_overview_does_not_mutate_the_collection_movie_record() {
let mut app = app_with_movie_overview();
let overview_before = app
.data
.radarr_data
.collection_movies
.current_selection()
.overview
.clone();
MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.down.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
)
.handle();
assert_eq!(overview_offset(&app), 1);
assert_eq!(
app
.data
.radarr_data
.collection_movies
.current_selection()
.overview,
overview_before
);
}
#[test]
fn test_movie_overview_handler_accepts() {
ActiveRadarrBlock::iter().for_each(|active_radarr_block| {
if active_radarr_block == ActiveRadarrBlock::ViewMovieOverview {
assert!(
MovieOverviewHandler::accepts(active_radarr_block),
"{active_radarr_block} is not accepted by the MovieOverviewHandler"
);
} else {
assert!(
!MovieOverviewHandler::accepts(active_radarr_block),
"{active_radarr_block} is wrongly accepted by the MovieOverviewHandler"
);
}
});
}
#[test]
fn test_movie_overview_handler_dispatches_no_network_events() {
let mut app = app_with_movie_overview();
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,
] {
MovieOverviewHandler::new(key, &mut app, ActiveRadarrBlock::ViewMovieOverview, None).handle();
assert_none!(app.data.radarr_data.prompt_confirm_action);
assert!(!app.data.radarr_data.prompt_confirm);
}
}
#[test]
fn test_movie_overview_handler_is_not_ready_when_loading() {
let mut app = app_with_movie_overview();
app.is_loading = true;
let handler = MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_movie_overview_handler_is_not_ready_when_the_modal_is_absent() {
let mut app = app_with_movie_overview();
app.data.radarr_data.movie_overview_modal = None;
let handler = MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_movie_overview_handler_is_ready() {
let mut app = app_with_movie_overview();
let handler = MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
);
assert!(handler.is_ready());
}
#[test]
fn test_movie_overview_handler_ignores_special_keys_for_textbox_input() {
let mut app = app_with_movie_overview();
app.ignore_special_keys_for_textbox_input = true;
let handler = MovieOverviewHandler::new(
DEFAULT_KEYBINDINGS.up.key,
&mut app,
ActiveRadarrBlock::ViewMovieOverview,
None,
);
assert!(handler.ignore_special_keys());
}
fn app_with_movie_overview() -> App<'static> {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into());
app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into());
app
}
fn overview_offset(app: &App<'_>) -> u16 {
app
.data
.radarr_data
.movie_overview_modal
.as_ref()
.unwrap()
.overview
.offset
}
fn set_offset(app: &mut App<'_>, offset: u16) {
app
.data
.radarr_data
.movie_overview_modal
.as_mut()
.unwrap()
.overview
.offset = offset;
}
}