feat(ui): Initial UI support for switching to Sonarr tabs

This commit is contained in:
2024-11-29 15:58:19 -07:00
parent 4d1b0fe301
commit 08f190fc6e
13 changed files with 537 additions and 19 deletions
@@ -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<f64>,
#[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,
}
}
+179
View File
@@ -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::<Vec<String>>()
.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(),
}
}
+211
View File
@@ -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::<Vec<Constraint>>(),
)
.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);
}
+16
View File
@@ -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()));
});
}
}