From 08f190fc6e22d05a1c5c8c9be7ee18044525d526 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 29 Nov 2024 15:58:19 -0700 Subject: [PATCH] feat(ui): Initial UI support for switching to Sonarr tabs --- src/app/app_tests.rs | 21 +- src/app/mod.rs | 2 +- src/app/radarr/mod.rs | 1 + src/app/sonarr/mod.rs | 1 + src/handlers/handlers_tests.rs | 8 + src/handlers/mod.rs | 2 + src/logos.rs | 17 +- src/ui/mod.rs | 15 +- src/ui/radarr_ui/mod.rs | 8 +- src/ui/sonarr_ui/library/library_ui_tests.rs | 75 +++++++ src/ui/sonarr_ui/library/mod.rs | 179 ++++++++++++++++ src/ui/sonarr_ui/mod.rs | 211 +++++++++++++++++++ src/ui/sonarr_ui/sonarr_ui_tests.rs | 16 ++ 13 files changed, 537 insertions(+), 19 deletions(-) create mode 100644 src/ui/sonarr_ui/library/library_ui_tests.rs create mode 100644 src/ui/sonarr_ui/library/mod.rs create mode 100644 src/ui/sonarr_ui/mod.rs create mode 100644 src/ui/sonarr_ui/sonarr_ui_tests.rs diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 61e2685..1e92f3b 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -5,9 +5,9 @@ mod tests { use tokio::sync::mpsc; use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES}; - use crate::app::{App, AppConfig, ServarrConfig, DEFAULT_ROUTE}; - use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; - use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::app::{App, AppConfig, Data, ServarrConfig, DEFAULT_ROUTE}; + use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::{HorizontallyScrollableText, TabRoute}; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkEvent; @@ -118,10 +118,23 @@ mod tests { #[test] fn test_reset() { + let radarr_data = RadarrData { + version: "test".into(), + ..RadarrData::default() + }; + let sonarr_data = SonarrData { + version: "test".into(), + ..SonarrData::default() + }; + let data = Data { + radarr_data, + sonarr_data, + }; let mut app = App { tick_count: 2, error: "Test error".to_owned().into(), is_first_render: false, + data, ..App::default() }; @@ -130,6 +143,8 @@ mod tests { assert_eq!(app.tick_count, 0); assert_eq!(app.error, HorizontallyScrollableText::default()); assert!(app.is_first_render); + assert!(app.data.radarr_data.version.is_empty()); + assert!(app.data.sonarr_data.version.is_empty()); } #[test] diff --git a/src/app/mod.rs b/src/app/mod.rs index fe13eed..3579ad8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -78,12 +78,12 @@ impl<'a> App<'a> { self.tick_count = 0; } - // Allowing this code for now since we'll eventually be implementing additional Servarr support and we'll need it then #[allow(dead_code)] pub fn reset(&mut self) { self.reset_tick_count(); self.error = HorizontallyScrollableText::default(); self.is_first_render = true; + self.data = Data::default(); } pub fn handle_error(&mut self, error: Error) { diff --git a/src/app/radarr/mod.rs b/src/app/radarr/mod.rs index 542de61..1ecbfda 100644 --- a/src/app/radarr/mod.rs +++ b/src/app/radarr/mod.rs @@ -141,6 +141,7 @@ impl<'a> App<'a> { self.refresh_radarr_metadata().await; self.dispatch_by_radarr_block(&active_radarr_block).await; self.is_first_render = false; + return; } if self.should_refresh { diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index f1b443b..0cefe77 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -138,6 +138,7 @@ impl<'a> App<'a> { self.refresh_sonarr_metadata().await; self.dispatch_by_sonarr_block(&active_sonarr_block).await; self.is_first_render = false; + return; } if self.should_refresh { diff --git a/src/handlers/handlers_tests.rs b/src/handlers/handlers_tests.rs index 5f23b02..7b2503e 100644 --- a/src/handlers/handlers_tests.rs +++ b/src/handlers/handlers_tests.rs @@ -10,6 +10,7 @@ mod tests { use crate::handlers::{handle_clear_errors, handle_prompt_toggle}; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::HorizontallyScrollableText; use crate::models::Route; #[test] @@ -30,19 +31,26 @@ mod tests { T: Into + Copy, { let mut app = App::default(); + app.error = "Test".into(); app.server_tabs.set_index(index); handle_events(DEFAULT_KEYBINDINGS.previous_servarr.key, &mut app); assert_eq!(app.server_tabs.get_active_route(), left_block.into()); assert_eq!(app.get_current_route(), left_block.into()); + assert!(app.is_first_render); + assert_eq!(app.error, HorizontallyScrollableText::default()); app.server_tabs.set_index(index); + app.is_first_render = false; + app.error = "Test".into(); handle_events(DEFAULT_KEYBINDINGS.next_servarr.key, &mut app); assert_eq!(app.server_tabs.get_active_route(), right_block.into()); assert_eq!(app.get_current_route(), right_block.into()); + assert!(app.is_first_render); + assert_eq!(app.error, HorizontallyScrollableText::default()); } #[rstest] diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 196ce1c..358c127 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -82,9 +82,11 @@ pub trait KeyEventHandler<'a, 'b, T: Into + Copy> { pub fn handle_events(key: Key, app: &mut App<'_>) { if key == DEFAULT_KEYBINDINGS.next_servarr.key { + app.reset(); app.server_tabs.next(); app.pop_and_push_navigation_stack(app.server_tabs.get_active_route()); } else if key == DEFAULT_KEYBINDINGS.previous_servarr.key { + app.reset(); app.server_tabs.previous(); app.pop_and_push_navigation_stack(app.server_tabs.get_active_route()); } else if let Route::Radarr(active_radarr_block, context) = app.get_current_route() { diff --git a/src/logos.rs b/src/logos.rs index d7ae5db..f0c6815 100644 --- a/src/logos.rs +++ b/src/logos.rs @@ -6,15 +6,14 @@ pub const RADARR_LOGO: &str = "⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⢿⡇⠀⠀⣀⣤⣶⡿⠛⠉⠀⠀⠀⠀ ⠀⠀⠰⠶⡿⠟⠋⠁⠀⠀⠀⠀⠀⠀⠀ "; -// Allowing this code for now since we'll eventually be implementing additional Servarr support and we'll need it then -#[allow(dead_code)] -pub const SONARR_LOGO: &str = "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⢀⣄⠙⠻⠟⠋⣤⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⢸⣿⠆⢾⡗⢸⣿⡇⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠈⠋⣠⣴⣦⣄⠛⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +pub const SONARR_LOGO: &str = "⠀⠀⠀⣠⣴⣶⣿⣿⣿⣿⣶⣦⣄⠀⠀⠀ +⠀⣠⡀⠈⠻⣿⣿⣿⣿⣿⣿⠟⠁⢀⣀⠀ +⢰⣿⣿⣦⠐⠄⠉⠉⠉⠉⠠⠂⣰⣿⣿⡆ +⣿⣿⣿⣿⡆⠀⣴⣿⣿⣦⠀⢰⣿⣿⣿⣿ +⣿⣿⣿⣿⡇⠀⠻⣿⣿⠟⠀⠸⣿⣿⣿⣿ +⠸⣿⣿⠟⠠⠂⠀⢀⡀⠀⠐⠄⠻⣿⣿⠇ +⠀⠙⠁⢀⣴⣾⣿⣿⣿⣿⣷⣦⡀⠈⠋⠀ +⠀⠀⠀⠘⠻⠿⣿⣿⣿⣿⠿⠟⠋⠀⠀⠀ "; // Allowing this code for now since we'll eventually be implementing additional Servarr support and we'll need it then #[allow(dead_code)] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 70b2637..61bc4a4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -8,6 +8,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Tabs; use ratatui::widgets::Wrap; use ratatui::Frame; +use sonarr_ui::SonarrUi; use crate::app::App; use crate::models::{HorizontallyScrollableText, Route, TabState}; @@ -20,6 +21,7 @@ use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::popup::Size; mod radarr_ui; +mod sonarr_ui; mod styles; mod utils; mod widgets; @@ -57,9 +59,16 @@ pub fn ui(f: &mut Frame<'_>, app: &mut App<'_>) { draw_header_row(f, app, header_area); - if RadarrUi::accepts(app.get_current_route()) { - RadarrUi::draw_context_row(f, app, context_area); - RadarrUi::draw(f, app, table_area); + match app.get_current_route() { + route if RadarrUi::accepts(route) => { + RadarrUi::draw_context_row(f, app, context_area); + RadarrUi::draw(f, app, table_area); + } + route if SonarrUi::accepts(route) => { + SonarrUi::draw_context_row(f, app, context_area); + SonarrUi::draw(f, app, table_area); + } + _ => (), } } diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index 0d2f10c..ddbb1a5 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -1,4 +1,4 @@ -use std::iter; +use std::{cmp, iter}; use chrono::{Duration, Utc}; use ratatui::layout::{Constraint, Layout, Rect}; @@ -178,15 +178,17 @@ fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { if !downloads_vec.is_empty() { f.render_widget(block, area); + let max_items = (((area.height as f64 / 2.0).floor() * 2.0) as usize) / 2; + let items = cmp::min(downloads_vec.len(), max_items - 1); let download_item_areas = Layout::vertical( iter::repeat(Constraint::Length(2)) - .take(downloads_vec.len()) + .take(items) .collect::>(), ) .margin(1) .split(area); - for i in 0..downloads_vec.len() { + for i in 0..items { let DownloadRecord { title, sizeleft, diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs new file mode 100644 index 0000000..31c40fc --- /dev/null +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -0,0 +1,75 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::{ + servarr_data::sonarr::sonarr_data::SERIES_BLOCKS, sonarr_models::SeriesStatus, + }; + use crate::ui::sonarr_ui::library::LibraryUi; + use crate::ui::styles::ManagarrStyle; + use crate::ui::DrawUi; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::{ + models::sonarr_models::{Series, SeriesStatistics}, + ui::sonarr_ui::library::decorate_series_row_with_style, + }; + + #[test] + fn test_library_ui_accepts() { + let mut library_ui_blocks = Vec::new(); + library_ui_blocks.extend(SERIES_BLOCKS); + + ActiveSonarrBlock::iter().for_each(|active_radarr_block| { + if library_ui_blocks.contains(&active_radarr_block) { + assert!(LibraryUi::accepts(active_radarr_block.into())); + } else { + assert!(!LibraryUi::accepts(active_radarr_block.into())); + } + }); + } + + #[rstest] + #[case(SeriesStatus::Ended, None, RowStyle::Missing)] + #[case(SeriesStatus::Ended, Some(59.0), RowStyle::Missing)] + #[case(SeriesStatus::Ended, Some(100.0), RowStyle::Downloaded)] + #[case(SeriesStatus::Continuing, None, RowStyle::Missing)] + #[case(SeriesStatus::Continuing, Some(59.0), RowStyle::Missing)] + #[case(SeriesStatus::Continuing, Some(100.0), RowStyle::Unreleased)] + #[case(SeriesStatus::Upcoming, None, RowStyle::Unreleased)] + #[case(SeriesStatus::Deleted, None, RowStyle::Missing)] + fn test_decorate_series_row_with_style( + #[case] series_status: SeriesStatus, + #[case] percent_of_episodes: Option, + #[case] expected_row_style: RowStyle, + ) { + let mut series = Series { + status: series_status, + ..Series::default() + }; + if let Some(percentage) = percent_of_episodes { + series.statistics = Some(SeriesStatistics { + percent_of_episodes: percentage, + ..SeriesStatistics::default() + }); + } + + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_series_row_with_style(&series, row.clone()); + + match expected_row_style { + RowStyle::Downloaded => assert_eq!(style, row.downloaded()), + RowStyle::Missing => assert_eq!(style, row.missing()), + RowStyle::Unreleased => assert_eq!(style, row.unreleased()), + } + } + + enum RowStyle { + Downloaded, + Missing, + Unreleased, + } +} diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs new file mode 100644 index 0000000..6b77a47 --- /dev/null +++ b/src/ui/sonarr_ui/library/mod.rs @@ -0,0 +1,179 @@ +use ratatui::{ + layout::{Constraint, Rect}, + widgets::{Cell, Row}, + Frame, +}; + +use crate::{ + app::App, + models::{ + servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_BLOCKS}, + sonarr_models::{Series, SeriesStatus}, + EnumDisplayStyle, Route, + }, + ui::{ + styles::ManagarrStyle, + utils::{get_width_from_percentage, layout_block_top_border}, + widgets::managarr_table::ManagarrTable, + DrawUi, + }, + utils::convert_runtime, +}; + +#[cfg(test)] +#[path = "library_ui_tests.rs"] +mod library_ui_tests; + +pub(super) struct LibraryUi; + +impl DrawUi for LibraryUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return SERIES_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); + let mut series_ui_matchers = |active_sonarr_block: ActiveSonarrBlock| match active_sonarr_block + { + ActiveSonarrBlock::Series | ActiveSonarrBlock::SeriesSortPrompt => draw_series(f, app, area), + _ => (), + }; + + match route { + Route::Sonarr(active_sonarr_block, _) if SERIES_BLOCKS.contains(&active_sonarr_block) => { + series_ui_matchers(active_sonarr_block) + } + _ => (), + } + } +} + +pub(super) fn draw_series(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let current_selection = if !app.data.sonarr_data.series.items.is_empty() { + app.data.sonarr_data.series.current_selection().clone() + } else { + Series::default() + }; + let quality_profile_map = &app.data.sonarr_data.quality_profile_map; + let language_profile_map = &app.data.sonarr_data.language_profiles_map; + let tags_map = &app.data.sonarr_data.tags_map; + let content = Some(&mut app.data.sonarr_data.series); + let help_footer = app + .data + .sonarr_data + .main_tabs + .get_active_tab_contextual_help(); + + let series_table_row_mapping = |series: &Series| { + series.title.scroll_left_or_reset( + get_width_from_percentage(area, 27), + *series == current_selection, + app.tick_count % app.ticks_until_scroll == 0, + ); + let monitored = if series.monitored { "🏷" } else { "" }; + let (hours, minutes) = convert_runtime(series.runtime); + let certification = series.certification.clone().unwrap_or_default(); + let network = series.network.clone().unwrap_or_default(); + let quality_profile = quality_profile_map + .get_by_left(&series.quality_profile_id) + .unwrap() + .to_owned(); + let language_profile = language_profile_map + .get_by_left(&series.language_profile_id) + .unwrap() + .to_owned(); + let tags = if !series.tags.is_empty() { + series + .tags + .iter() + .map(|tag_id| { + tags_map + .get_by_left(&tag_id.as_i64().unwrap()) + .unwrap() + .clone() + }) + .collect::>() + .join(", ") + } else { + String::new() + }; + + decorate_series_row_with_style( + series, + Row::new(vec![ + Cell::from(series.title.to_string()), + Cell::from(series.year.to_string()), + Cell::from(network), + Cell::from(format!("{hours}h {minutes}m")), + Cell::from(certification), + Cell::from(series.series_type.to_display_str()), + Cell::from(quality_profile), + Cell::from(language_profile), + Cell::from(monitored.to_owned()), + Cell::from(tags), + ]), + ) + }; + let series_table = ManagarrTable::new(content, series_table_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(help_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::SeriesSortPrompt) + .headers([ + "Title", + "Year", + "Network", + "Runtime", + "Rating", + "Type", + "Quality Profile", + "Language Profile", + "Monitored", + "Tags", + ]) + .constraints([ + Constraint::Percentage(27), + Constraint::Percentage(4), + Constraint::Percentage(10), + Constraint::Percentage(6), + Constraint::Percentage(6), + Constraint::Percentage(6), + Constraint::Percentage(13), + Constraint::Percentage(10), + Constraint::Percentage(6), + Constraint::Percentage(12), + ]); + + f.render_widget(series_table, area); + } +} + +fn decorate_series_row_with_style<'a>(series: &Series, row: Row<'a>) -> Row<'a> { + match series.status { + SeriesStatus::Ended => { + if let Some(ref stats) = series.statistics { + if stats.percent_of_episodes == 100.0 { + return row.downloaded(); + } + } + + row.missing() + } + SeriesStatus::Continuing => { + if let Some(ref stats) = series.statistics { + if stats.percent_of_episodes == 100.0 { + return row.unreleased(); + } + } + + row.missing() + } + SeriesStatus::Upcoming => row.unreleased(), + _ => row.missing(), + } +} diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs new file mode 100644 index 0000000..dbe45f5 --- /dev/null +++ b/src/ui/sonarr_ui/mod.rs @@ -0,0 +1,211 @@ +use std::{cmp, iter}; + +use chrono::{Duration, Utc}; +use library::LibraryUi; +use log::debug; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::Stylize, + text::Text, + widgets::Paragraph, + Frame, +}; + +use crate::{ + app::App, + logos::SONARR_LOGO, + models::{ + servarr_data::sonarr::sonarr_data::SonarrData, + servarr_models::{DiskSpace, RootFolder}, + sonarr_models::DownloadRecord, + Route, + }, + utils::convert_to_gb, +}; + +use super::{ + draw_tabs, + styles::ManagarrStyle, + utils::{ + borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block, + }, + widgets::loading_block::LoadingBlock, + DrawUi, +}; + +mod library; + +#[cfg(test)] +#[path = "sonarr_ui_tests.rs"] +mod sonarr_ui_tests; + +pub(super) struct SonarrUi; + +impl DrawUi for SonarrUi { + fn accepts(route: Route) -> bool { + matches!(route, Route::Sonarr(_, _)) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let content_area = draw_tabs(f, area, "Series", &app.data.sonarr_data.main_tabs); + let route = app.get_current_route(); + + match route { + _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), + _ => (), + } + } + + fn draw_context_row(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let [main_area, logo_area] = + Layout::horizontal([Constraint::Fill(0), Constraint::Length(20)]).areas(area); + + let [stats_area, downloads_area] = + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(main_area); + + draw_stats_context(f, app, stats_area); + draw_downloads_context(f, app, downloads_area); + draw_sonarr_logo(f, logo_area); + } +} + +fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block("Stats"); + + if !app.data.sonarr_data.version.is_empty() { + f.render_widget(block, area); + let SonarrData { + root_folders, + disk_space_vec, + start_time, + .. + } = &app.data.sonarr_data; + + let mut constraints = vec![ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]; + + constraints.append( + &mut iter::repeat(Constraint::Length(1)) + .take(disk_space_vec.len() + root_folders.items.len() + 1) + .collect(), + ); + + let stat_item_areas = Layout::vertical(constraints).margin(1).split(area); + + let version_paragraph = Paragraph::new(Text::from(format!( + "Sonarr Version: {}", + app.data.sonarr_data.version + ))) + .block(borderless_block()) + .bold(); + + let uptime = Utc::now() - start_time.to_owned(); + let days = uptime.num_days(); + let day_difference = uptime - Duration::days(days); + let hours = day_difference.num_hours(); + let hour_difference = day_difference - Duration::hours(hours); + let minutes = hour_difference.num_minutes(); + let seconds = (hour_difference - Duration::minutes(minutes)).num_seconds(); + + let uptime_paragraph = Paragraph::new(Text::from(format!( + "Uptime: {days}d {hours:0width$}:{minutes:0width$}:{seconds:0width$}", + width = 2 + ))) + .block(borderless_block()) + .bold(); + + let storage = Paragraph::new(Text::from("Storage:")).block(borderless_block().bold()); + let folders = Paragraph::new(Text::from("Root Folders:")).block(borderless_block().bold()); + + f.render_widget(version_paragraph, stat_item_areas[0]); + f.render_widget(uptime_paragraph, stat_item_areas[1]); + f.render_widget(storage, stat_item_areas[2]); + + for i in 0..disk_space_vec.len() { + let DiskSpace { + free_space, + total_space, + } = &disk_space_vec[i]; + let title = format!("Disk {}", i + 1); + let ratio = if *total_space == 0 { + 0f64 + } else { + 1f64 - (*free_space as f64 / *total_space as f64) + }; + + let space_gauge = line_gauge_with_label(title.as_str(), ratio); + + f.render_widget(space_gauge, stat_item_areas[i + 3]); + } + + f.render_widget(folders, stat_item_areas[disk_space_vec.len() + 3]); + + for i in 0..root_folders.items.len() { + let RootFolder { + path, free_space, .. + } = &root_folders.items[i]; + let space: f64 = convert_to_gb(*free_space); + let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) + .block(borderless_block()) + .default(); + + f.render_widget( + root_folder_space, + stat_item_areas[i + disk_space_vec.len() + 4], + ) + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} + +fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block("Downloads"); + let downloads_vec = &app.data.sonarr_data.downloads.items; + + if !downloads_vec.is_empty() { + f.render_widget(block, area); + let max_items = (((area.height as f64 / 2.0).floor() * 2.0) as usize) / 2; + + let items = cmp::min(downloads_vec.len(), max_items - 1); + debug!("Items: {items}"); + let download_item_areas = Layout::vertical( + iter::repeat(Constraint::Length(2)) + .take(items) + .collect::>(), + ) + .margin(1) + .split(area); + + for i in 0..items { + let DownloadRecord { + title, + sizeleft, + size, + .. + } = &downloads_vec[i]; + let percent = if *size == 0.0 { + 0.0 + } else { + 1f64 - (*sizeleft / *size) + }; + let download_gauge = line_gauge_with_title(title, percent); + + f.render_widget(download_gauge, download_item_areas[i]); + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} + +fn draw_sonarr_logo(f: &mut Frame<'_>, area: Rect) { + let logo_text = Text::from(SONARR_LOGO); + let logo = Paragraph::new(logo_text) + .light_cyan() + .block(layout_block().default()) + .centered(); + f.render_widget(logo, area); +} diff --git a/src/ui/sonarr_ui/sonarr_ui_tests.rs b/src/ui/sonarr_ui/sonarr_ui_tests.rs new file mode 100644 index 0000000..6a1630e --- /dev/null +++ b/src/ui/sonarr_ui/sonarr_ui_tests.rs @@ -0,0 +1,16 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::{ + models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, + ui::{sonarr_ui::SonarrUi, DrawUi}, + }; + + #[test] + fn test_sonarr_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + assert!(SonarrUi::accepts(active_sonarr_block.into())); + }); + } +}