feat(ui): Initial UI support for switching to Sonarr tabs
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user