feat(ui): Initial UI support for switching to Sonarr tabs
This commit is contained in:
+18
-3
@@ -5,9 +5,9 @@ mod tests {
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES};
|
use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES};
|
||||||
use crate::app::{App, AppConfig, ServarrConfig, DEFAULT_ROUTE};
|
use crate::app::{App, AppConfig, Data, ServarrConfig, DEFAULT_ROUTE};
|
||||||
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
|
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
|
||||||
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
|
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
|
||||||
use crate::models::{HorizontallyScrollableText, TabRoute};
|
use crate::models::{HorizontallyScrollableText, TabRoute};
|
||||||
use crate::network::radarr_network::RadarrEvent;
|
use crate::network::radarr_network::RadarrEvent;
|
||||||
use crate::network::NetworkEvent;
|
use crate::network::NetworkEvent;
|
||||||
@@ -118,10 +118,23 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_reset() {
|
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 {
|
let mut app = App {
|
||||||
tick_count: 2,
|
tick_count: 2,
|
||||||
error: "Test error".to_owned().into(),
|
error: "Test error".to_owned().into(),
|
||||||
is_first_render: false,
|
is_first_render: false,
|
||||||
|
data,
|
||||||
..App::default()
|
..App::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,6 +143,8 @@ mod tests {
|
|||||||
assert_eq!(app.tick_count, 0);
|
assert_eq!(app.tick_count, 0);
|
||||||
assert_eq!(app.error, HorizontallyScrollableText::default());
|
assert_eq!(app.error, HorizontallyScrollableText::default());
|
||||||
assert!(app.is_first_render);
|
assert!(app.is_first_render);
|
||||||
|
assert!(app.data.radarr_data.version.is_empty());
|
||||||
|
assert!(app.data.sonarr_data.version.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+1
-1
@@ -78,12 +78,12 @@ impl<'a> App<'a> {
|
|||||||
self.tick_count = 0;
|
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)]
|
#[allow(dead_code)]
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.reset_tick_count();
|
self.reset_tick_count();
|
||||||
self.error = HorizontallyScrollableText::default();
|
self.error = HorizontallyScrollableText::default();
|
||||||
self.is_first_render = true;
|
self.is_first_render = true;
|
||||||
|
self.data = Data::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_error(&mut self, error: Error) {
|
pub fn handle_error(&mut self, error: Error) {
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ impl<'a> App<'a> {
|
|||||||
self.refresh_radarr_metadata().await;
|
self.refresh_radarr_metadata().await;
|
||||||
self.dispatch_by_radarr_block(&active_radarr_block).await;
|
self.dispatch_by_radarr_block(&active_radarr_block).await;
|
||||||
self.is_first_render = false;
|
self.is_first_render = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.should_refresh {
|
if self.should_refresh {
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ impl<'a> App<'a> {
|
|||||||
self.refresh_sonarr_metadata().await;
|
self.refresh_sonarr_metadata().await;
|
||||||
self.dispatch_by_sonarr_block(&active_sonarr_block).await;
|
self.dispatch_by_sonarr_block(&active_sonarr_block).await;
|
||||||
self.is_first_render = false;
|
self.is_first_render = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.should_refresh {
|
if self.should_refresh {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod tests {
|
|||||||
use crate::handlers::{handle_clear_errors, handle_prompt_toggle};
|
use crate::handlers::{handle_clear_errors, handle_prompt_toggle};
|
||||||
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
|
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
|
||||||
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
|
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
|
||||||
|
use crate::models::HorizontallyScrollableText;
|
||||||
use crate::models::Route;
|
use crate::models::Route;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -30,19 +31,26 @@ mod tests {
|
|||||||
T: Into<Route> + Copy,
|
T: Into<Route> + Copy,
|
||||||
{
|
{
|
||||||
let mut app = App::default();
|
let mut app = App::default();
|
||||||
|
app.error = "Test".into();
|
||||||
app.server_tabs.set_index(index);
|
app.server_tabs.set_index(index);
|
||||||
|
|
||||||
handle_events(DEFAULT_KEYBINDINGS.previous_servarr.key, &mut app);
|
handle_events(DEFAULT_KEYBINDINGS.previous_servarr.key, &mut app);
|
||||||
|
|
||||||
assert_eq!(app.server_tabs.get_active_route(), left_block.into());
|
assert_eq!(app.server_tabs.get_active_route(), left_block.into());
|
||||||
assert_eq!(app.get_current_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.server_tabs.set_index(index);
|
||||||
|
app.is_first_render = false;
|
||||||
|
app.error = "Test".into();
|
||||||
|
|
||||||
handle_events(DEFAULT_KEYBINDINGS.next_servarr.key, &mut app);
|
handle_events(DEFAULT_KEYBINDINGS.next_servarr.key, &mut app);
|
||||||
|
|
||||||
assert_eq!(app.server_tabs.get_active_route(), right_block.into());
|
assert_eq!(app.server_tabs.get_active_route(), right_block.into());
|
||||||
assert_eq!(app.get_current_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]
|
#[rstest]
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ pub trait KeyEventHandler<'a, 'b, T: Into<Route> + Copy> {
|
|||||||
|
|
||||||
pub fn handle_events(key: Key, app: &mut App<'_>) {
|
pub fn handle_events(key: Key, app: &mut App<'_>) {
|
||||||
if key == DEFAULT_KEYBINDINGS.next_servarr.key {
|
if key == DEFAULT_KEYBINDINGS.next_servarr.key {
|
||||||
|
app.reset();
|
||||||
app.server_tabs.next();
|
app.server_tabs.next();
|
||||||
app.pop_and_push_navigation_stack(app.server_tabs.get_active_route());
|
app.pop_and_push_navigation_stack(app.server_tabs.get_active_route());
|
||||||
} else if key == DEFAULT_KEYBINDINGS.previous_servarr.key {
|
} else if key == DEFAULT_KEYBINDINGS.previous_servarr.key {
|
||||||
|
app.reset();
|
||||||
app.server_tabs.previous();
|
app.server_tabs.previous();
|
||||||
app.pop_and_push_navigation_stack(app.server_tabs.get_active_route());
|
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() {
|
} else if let Route::Radarr(active_radarr_block, context) = app.get_current_route() {
|
||||||
|
|||||||
+8
-9
@@ -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
|
pub const SONARR_LOGO: &str = "⠀⠀⠀⣠⣴⣶⣿⣿⣿⣿⣶⣦⣄⠀⠀⠀
|
||||||
#[allow(dead_code)]
|
⠀⣠⡀⠈⠻⣿⣿⣿⣿⣿⣿⠟⠁⢀⣀⠀
|
||||||
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
|
// Allowing this code for now since we'll eventually be implementing additional Servarr support and we'll need it then
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
+12
-3
@@ -8,6 +8,7 @@ use ratatui::widgets::Paragraph;
|
|||||||
use ratatui::widgets::Tabs;
|
use ratatui::widgets::Tabs;
|
||||||
use ratatui::widgets::Wrap;
|
use ratatui::widgets::Wrap;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
use sonarr_ui::SonarrUi;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::models::{HorizontallyScrollableText, Route, TabState};
|
use crate::models::{HorizontallyScrollableText, Route, TabState};
|
||||||
@@ -20,6 +21,7 @@ use crate::ui::widgets::input_box::InputBox;
|
|||||||
use crate::ui::widgets::popup::Size;
|
use crate::ui::widgets::popup::Size;
|
||||||
|
|
||||||
mod radarr_ui;
|
mod radarr_ui;
|
||||||
|
mod sonarr_ui;
|
||||||
mod styles;
|
mod styles;
|
||||||
mod utils;
|
mod utils;
|
||||||
mod widgets;
|
mod widgets;
|
||||||
@@ -57,9 +59,16 @@ pub fn ui(f: &mut Frame<'_>, app: &mut App<'_>) {
|
|||||||
|
|
||||||
draw_header_row(f, app, header_area);
|
draw_header_row(f, app, header_area);
|
||||||
|
|
||||||
if RadarrUi::accepts(app.get_current_route()) {
|
match app.get_current_route() {
|
||||||
RadarrUi::draw_context_row(f, app, context_area);
|
route if RadarrUi::accepts(route) => {
|
||||||
RadarrUi::draw(f, app, table_area);
|
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);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::iter;
|
use std::{cmp, iter};
|
||||||
|
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
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() {
|
if !downloads_vec.is_empty() {
|
||||||
f.render_widget(block, area);
|
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(
|
let download_item_areas = Layout::vertical(
|
||||||
iter::repeat(Constraint::Length(2))
|
iter::repeat(Constraint::Length(2))
|
||||||
.take(downloads_vec.len())
|
.take(items)
|
||||||
.collect::<Vec<Constraint>>(),
|
.collect::<Vec<Constraint>>(),
|
||||||
)
|
)
|
||||||
.margin(1)
|
.margin(1)
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
for i in 0..downloads_vec.len() {
|
for i in 0..items {
|
||||||
let DownloadRecord {
|
let DownloadRecord {
|
||||||
title,
|
title,
|
||||||
sizeleft,
|
sizeleft,
|
||||||
|
|||||||
@@ -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