From d39acb06831fc5f16c12cc9bb64ce953f155f13f Mon Sep 17 00:00:00 2001 From: Dark-Alex-17 Date: Tue, 8 Aug 2023 10:50:04 -0600 Subject: [PATCH] Added better support for contexts now and improved base Radarr UI --- src/app/key_binding.rs | 12 +- src/app/mod.rs | 69 ++++++++-- src/app/radarr.rs | 38 ++++- src/event/key.rs | 6 + src/handlers/mod.rs | 10 ++ src/main.rs | 16 +-- src/network/mod.rs | 18 ++- src/network/{radarr.rs => radarr_network.rs} | 96 +++++++++++-- src/ui/mod.rs | 118 +--------------- src/ui/radarr_ui.rs | 138 +++++++++++++++++++ src/ui/utils.rs | 39 ++++++ 11 files changed, 407 insertions(+), 153 deletions(-) rename src/network/{radarr.rs => radarr_network.rs} (50%) create mode 100644 src/ui/radarr_ui.rs diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index 6e38e42..2a09062 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -11,7 +11,9 @@ macro_rules! generate_keybindings { generate_keybindings! { quit, up, - down + down, + submit, + esc } pub struct KeyBinding { @@ -31,5 +33,13 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { down: KeyBinding { key: Key::Down, desc: "Scroll down" + }, + submit: KeyBinding { + key: Key::Enter, + desc: "Select" + }, + esc: KeyBinding { + key: Key::Esc, + desc: "Exit menu" } }; \ No newline at end of file diff --git a/src/app/mod.rs b/src/app/mod.rs index d58330f..01de144 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,25 +4,34 @@ use serde::{Deserialize, Serialize}; use tokio::sync::mpsc::Sender; use tui::widgets::TableState; -use crate::app::radarr::RadarrData; - -use super::network::RadarrEvent; +use crate::app::radarr::{ActiveRadarrBlock, RadarrData}; +use crate::network::NetworkEvent; +use crate::network::radarr_network::RadarrEvent; pub(crate) mod key_binding; pub mod radarr; +#[derive(Clone, PartialEq, Eq)] +pub enum Route { + Radarr(ActiveRadarrBlock), +} + +const DEFAULT_ROUTE: Route = Route::Radarr(ActiveRadarrBlock::Movies); + pub struct App { - network_tx: Option>, + navigation_stack: Vec, + network_tx: Option>, pub client: Client, pub title: &'static str, pub tick_until_poll: u64, pub tick_count: u64, + pub is_routing: bool, pub config: AppConfig, pub data: Data, } impl App { - pub fn new(network_tx: Sender, tick_until_poll: u64, config: AppConfig) -> Self { + pub fn new(network_tx: Sender, tick_until_poll: u64, config: AppConfig) -> Self { App { network_tx: Some(network_tx), tick_until_poll, @@ -31,7 +40,7 @@ impl App { } } - pub async fn dispatch(&mut self, action: RadarrEvent) { + pub async fn dispatch(&mut self, action: NetworkEvent) { if let Some(network_tx) = &self.network_tx { if let Err(e) = network_tx.send(action).await { error!("Failed to send event. {:?}", e); @@ -43,25 +52,61 @@ impl App { self.tick_count = 0; } - pub async fn on_tick(&mut self) { - if self.tick_count % self.tick_until_poll == 0 { - self.dispatch(RadarrEvent::GetOverview).await; - self.dispatch(RadarrEvent::GetStatus).await; - self.dispatch(RadarrEvent::GetMovies).await; + pub fn reset(&mut self) { + self.reset_tick_count(); + self.data = Data::default(); + } + + pub async fn on_tick(&mut self, is_first_render: bool) { + if self.tick_count % self.tick_until_poll == 0 || self.is_routing { + match self.get_current_route() { + Route::Radarr(active_radarr_block) => { + let active_block = active_radarr_block.clone(); + + if is_first_render { + self.dispatch(RadarrEvent::GetQualityProfiles.into()).await; + } + + self.dispatch(RadarrEvent::GetOverview.into()).await; + self.dispatch(RadarrEvent::GetStatus.into()).await; + + + self.dispatch_by_radarr_block(active_block).await; + } + } + + self.is_routing = false; } self.tick_count += 1; } + + pub fn push_navigation_stack(&mut self, route: Route) { + self.navigation_stack.push(route); + self.is_routing = true; + } + + pub fn pop_navigation_stack(&mut self) { + if self.navigation_stack.len() > 1 { + self.navigation_stack.pop(); + } + } + + pub fn get_current_route(&self) -> &Route { + self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE) + } } impl Default for App { fn default() -> Self { App { + navigation_stack: vec![DEFAULT_ROUTE], network_tx: None, client: Client::new(), - title: "DevTools", + title: "Managarr", tick_until_poll: 0, tick_count: 0, + is_routing: false, config: AppConfig::default(), data: Data::default(), } diff --git a/src/app/radarr.rs b/src/app/radarr.rs index 4bbf4d7..5b3f447 100644 --- a/src/app/radarr.rs +++ b/src/app/radarr.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; + use chrono::{DateTime, Utc}; -use crate::app::StatefulTable; -use crate::network::radarr::Movie; +use crate::app::{App, StatefulTable}; +use crate::network::radarr_network::{DownloadRecord, Movie, RadarrEvent}; #[derive(Default)] pub struct RadarrData { @@ -9,5 +11,35 @@ pub struct RadarrData { pub total_space: u64, pub version: String, pub start_time: DateTime, - pub movies: StatefulTable + pub movies: StatefulTable, + pub downloads: StatefulTable, + pub quality_profile_map: HashMap, +} + +#[derive(Clone, PartialEq, Eq)] +pub enum ActiveRadarrBlock { + AddMovie, + Calendar, + Collections, + Events, + Logs, + Movies, + MovieDetails, + Downloads, + SearchMovie, + SortOptions, + Tasks, +} + +impl App { + pub(super) async fn dispatch_by_radarr_block(&mut self, active_radarr_block: ActiveRadarrBlock) { + match active_radarr_block { + ActiveRadarrBlock::Downloads => self.dispatch(RadarrEvent::GetDownloads.into()).await, + ActiveRadarrBlock::Movies => { + self.dispatch(RadarrEvent::GetMovies.into()).await; + self.dispatch(RadarrEvent::GetDownloads.into()).await; + }, + _ => () + } + } } diff --git a/src/event/key.rs b/src/event/key.rs index 3b0c5c4..8602f63 100644 --- a/src/event/key.rs +++ b/src/event/key.rs @@ -7,6 +7,8 @@ use crossterm::event::{KeyCode, KeyEvent}; pub enum Key { Up, Down, + Enter, + Esc, Char(char), Unknown, } @@ -31,6 +33,10 @@ impl From for Key { code: KeyCode::Down, .. } => Key::Down, + KeyEvent { + code: KeyCode::Enter, + .. + } => Key::Enter, KeyEvent { code: KeyCode::Char(c), .. diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index eebc2f8..ae89321 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -6,6 +6,8 @@ pub async fn handle_key_events(key: Key, app: &mut App) { match key { _ if key == DEFAULT_KEYBINDINGS.up.key => handle_scroll_up(app).await, _ if key == DEFAULT_KEYBINDINGS.down.key => handle_scroll_down(app).await, + _ if key == DEFAULT_KEYBINDINGS.submit.key => handle_submit(app).await, + _ if key == DEFAULT_KEYBINDINGS.esc.key => handle_esc(app).await, _ => () } } @@ -16,4 +18,12 @@ async fn handle_scroll_up(app: &mut App) { async fn handle_scroll_down(app: &mut App) { app.data.radarr_data.movies.scroll_down(); +} + +async fn handle_submit(app: &mut App) { + todo!() +} + +async fn handle_esc(app: &mut App) { + todo!() } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index d300a52..18cc7f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; -use log::{debug, info}; +use log::debug; use tokio::sync::{mpsc, Mutex}; use tokio::sync::mpsc::Receiver; use tui::backend::CrosstermBackend; @@ -16,7 +16,7 @@ use tui::Terminal; use crate::app::App; use crate::event::input_event::{Events, InputEvent}; use crate::event::Key; -use crate::network::{Network, RadarrEvent}; +use crate::network::{Network, NetworkEvent}; use crate::ui::ui; mod app; @@ -45,21 +45,18 @@ async fn main() -> Result<()> { std::thread::spawn(move || start_networking(sync_network_rx, &app_nw)); - info!("Checking if Radarr server is up and running..."); - app.lock().await.dispatch(RadarrEvent::HealthCheck).await; - start_ui(&app).await?; Ok(()) } #[tokio::main] -async fn start_networking(mut network_rx: Receiver, app: &Arc>) { +async fn start_networking(mut network_rx: Receiver, app: &Arc>) { let network = Network::new(reqwest::Client::new(), app); while let Some(network_event) = network_rx.recv().await { debug!("Received network event: {:?}", network_event); - network.handle_radarr_event(network_event).await; + network.handle_network_event(network_event).await; } } @@ -74,6 +71,7 @@ async fn start_ui(app: &Arc>) -> Result<()> { terminal.hide_cursor()?; let input_events = Events::new(); + let mut is_first_render = true; loop { let mut app = app.lock().await; @@ -87,8 +85,10 @@ async fn start_ui(app: &Arc>) -> Result<()> { handlers::handle_key_events(key, &mut app).await; } - InputEvent::Tick => app.on_tick().await, + InputEvent::Tick => app.on_tick(is_first_render).await, } + + is_first_render = false; } terminal.show_cursor()?; diff --git a/src/network/mod.rs b/src/network/mod.rs index b03e95e..a680ac7 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -4,16 +4,14 @@ use reqwest::Client; use tokio::sync::Mutex; use crate::app::App; +use crate::network::radarr_network::RadarrEvent; -pub(crate) mod radarr; +pub(crate) mod radarr_network; mod utils; -#[derive(Debug, Eq, PartialEq, Hash)] -pub enum RadarrEvent { - HealthCheck, - GetOverview, - GetStatus, - GetMovies, +#[derive(PartialEq, Eq, Debug)] +pub enum NetworkEvent { + Radarr(RadarrEvent) } pub struct Network<'a> { @@ -26,4 +24,10 @@ impl<'a> Network<'a> { pub fn new(client: Client, app: &'a Arc>) -> Self { Network { client, app } } + + pub async fn handle_network_event(&self, network_event: NetworkEvent) { + match network_event { + NetworkEvent::Radarr(radarr_event) => self.handle_radarr_event(radarr_event).await + } + } } diff --git a/src/network/radarr.rs b/src/network/radarr_network.rs similarity index 50% rename from src/network/radarr.rs rename to src/network/radarr_network.rs index 3443bc5..ed4f9cd 100644 --- a/src/network/radarr.rs +++ b/src/network/radarr_network.rs @@ -8,7 +8,17 @@ use serde_json::Number; use tokio::sync::MutexGuard; use crate::app::{App, RadarrConfig}; -use crate::network::{Network, RadarrEvent, utils}; +use crate::network::{Network, NetworkEvent, utils}; + +#[derive(Debug, Eq, PartialEq)] +pub enum RadarrEvent { + HealthCheck, + GetDownloads, + GetOverview, + GetStatus, + GetMovies, + GetQualityProfiles, +} impl RadarrEvent { const fn resource(self) -> &'static str { @@ -17,10 +27,18 @@ impl RadarrEvent { RadarrEvent::GetOverview => "/diskspace", RadarrEvent::GetStatus => "/system/status", RadarrEvent::GetMovies => "/movie", + RadarrEvent::GetDownloads => "/queue", + RadarrEvent::GetQualityProfiles => "/qualityprofile" } } } +impl From for NetworkEvent { + fn from(radarr_event: RadarrEvent) -> Self { + NetworkEvent::Radarr(radarr_event) + } +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct DiskSpace { @@ -45,31 +63,73 @@ pub struct Movie { pub id: Number, pub title: String, #[derivative(Default(value = "Number::from(0)"))] + pub size_on_disk: Number, + pub status: String, + #[derivative(Default(value = "Number::from(0)"))] pub year: Number, pub monitored: bool, pub has_file: bool, + #[derivative(Default(value = "Number::from(0)"))] + pub runtime: Number, + #[derivative(Default(value = "Number::from(0)"))] + pub quality_profile_id: Number, +} + +#[derive(Derivative, Deserialize, Debug)] +#[derivative(Default)] +#[serde(rename_all = "camelCase")] +pub struct DownloadsResponse { + pub records: Vec +} + +#[derive(Derivative, Deserialize, Debug)] +#[derivative(Default)] +#[serde(rename_all = "camelCase")] +pub struct DownloadRecord { + pub title: String, + pub status: String, + #[derivative(Default(value = "Number::from(0)"))] + pub movie_id: Number, + #[derivative(Default(value = "Number::from(0)"))] + pub size: Number, + #[derivative(Default(value = "Number::from(0)"))] + pub sizeleft: Number, + pub output_path: String, + pub indexer: String, + pub download_client: String +} + +#[derive(Derivative, Deserialize, Debug)] +#[derivative(Default)] +#[serde(rename_all = "camelCase")] +struct QualityProfile { + #[derivative(Default(value = "Number::from(0)"))] + pub id: Number, + pub name: String } impl<'a> Network<'a> { pub async fn handle_radarr_event(&self, radarr_event: RadarrEvent) { match radarr_event { - RadarrEvent::HealthCheck => self.healthcheck(RadarrEvent::HealthCheck.resource()).await, - RadarrEvent::GetOverview => self.diskspace(RadarrEvent::GetOverview.resource()).await, - RadarrEvent::GetStatus => self.status(RadarrEvent::GetStatus.resource()).await, - RadarrEvent::GetMovies => self.movies(RadarrEvent::GetMovies.resource()).await + RadarrEvent::HealthCheck => self.get_healthcheck(RadarrEvent::HealthCheck.resource()).await, + RadarrEvent::GetOverview => self.get_diskspace(RadarrEvent::GetOverview.resource()).await, + RadarrEvent::GetStatus => self.get_status(RadarrEvent::GetStatus.resource()).await, + RadarrEvent::GetMovies => self.get_movies(RadarrEvent::GetMovies.resource()).await, + RadarrEvent::GetDownloads => self.get_downloads(RadarrEvent::GetDownloads.resource()).await, + RadarrEvent::GetQualityProfiles => self.get_quality_profiles(RadarrEvent::GetQualityProfiles.resource()).await } let mut app = self.app.lock().await; app.reset_tick_count(); } - async fn healthcheck(&self, resource: &str) { + async fn get_healthcheck(&self, resource: &str) { if let Err(e) = self.call_radarr_api(resource).await.send().await { error!("Healthcheck failed. {:?}", e) } } - async fn diskspace(&self, resource: &str) { + async fn get_diskspace(&self, resource: &str) { self.handle_get_request::>(resource, | disk_space_vec, mut app | { let DiskSpace { free_space, @@ -82,19 +142,33 @@ impl<'a> Network<'a> { }).await; } - async fn status(&self, resource: &str) { + async fn get_status(&self, resource: &str) { self.handle_get_request::(resource, | system_status, mut app | { app.data.radarr_data.version = system_status.version; app.data.radarr_data.start_time = system_status.start_time; }).await; } - async fn movies(&self, resource: &str) { + async fn get_movies(&self, resource: &str) { self.handle_get_request::>(resource, |movie_vec, mut app| { app.data.radarr_data.movies.set_items(movie_vec); }).await; } + async fn get_downloads(&self, resource: &str) { + self.handle_get_request::(resource, |queue_response, mut app | { + app.data.radarr_data.downloads.set_items(queue_response.records); + }).await + } + + async fn get_quality_profiles(&self, resource: &str) { + self.handle_get_request::>(resource, | quality_profiles, mut app | { + app.data.radarr_data.quality_profile_map = quality_profiles.into_iter() + .map(| profile | (profile.id.as_u64().unwrap(), profile.name)) + .collect(); + }).await; + } + async fn call_radarr_api(&self, resource: &str) -> RequestBuilder { debug!("Creating RequestBuilder for resource: {:?}", resource); let app = self.app.lock().await; @@ -128,10 +202,10 @@ impl<'a> Network<'a> { let app = self.app.lock().await; app_update_fn(value, app); } - Err(e) => error!("Failed to parse movie response! {:?}", e) + Err(e) => error!("Failed to parse response! {:?}", e) } } - Err(e) => error!("Failed to fetch movies. {:?}", e) + Err(e) => error!("Failed to fetch resource. {:?}", e) } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index cc805ee..da3c1ae 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,15 +1,9 @@ -use std::ops::Sub; - -use chrono::{Duration, Utc}; -use tui::{Frame, symbols}; use tui::backend::Backend; -use tui::layout::{Alignment, Constraint, Rect}; -use tui::style::{Color, Modifier, Style}; -use tui::text::{Span, Spans, Text}; -use tui::widgets::{Block, Borders, Cell, LineGauge, Paragraph, Row, Table}; +use tui::Frame; +use tui::layout::{Constraint, Rect}; +use tui::widgets::{Block, Borders}; use crate::app::App; -use crate::app::radarr::RadarrData; use crate::logos::{ BAZARR_LOGO, LIDARR_LOGO, PROWLARR_LOGO, RADARR_LOGO, READARR_LOGO, SONARR_LOGO, }; @@ -18,6 +12,7 @@ use crate::ui::utils::{ }; mod utils; +mod radarr_ui; static HIGHLIGHT_SYMBOL: &str = "=> "; @@ -29,7 +24,7 @@ pub fn ui(f: &mut Frame, app: &mut App) { ); draw_context_row(f, app, main_chunks[0]); - draw_radarr_ui(f, app, main_chunks[1]); + radarr_ui::draw_radarr_ui(f, app, main_chunks[1]); } fn draw_context_row(f: &mut Frame<'_, B>, app: &App, area: Rect) { @@ -44,109 +39,10 @@ fn draw_context_row(f: &mut Frame<'_, B>, app: &App, area: Rect) { area, ); - draw_stats(f, app, chunks[0]); + radarr_ui::draw_stats(f, app, chunks[0]); f.render_widget(Block::default().borders(Borders::ALL), chunks[1]); f.render_widget(Block::default().borders(Borders::ALL), chunks[2]); f.render_widget(Block::default().borders(Borders::ALL), chunks[3]); - draw_logo(f, chunks[4]); -} - -fn draw_radarr_ui(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { - let block = Block::default().borders(Borders::ALL).title(Spans::from(vec![ - Span::styled("Movies", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) - ])); - - let row_style = Style::default().fg(Color::Cyan); - let rows = app.data.radarr_data.movies.items - .iter() - .map(|movie| Row::new(vec![ - Cell::from(movie.title.to_owned()), - Cell::from(movie.year.to_string()), - Cell::from(movie.monitored.to_string()), - Cell::from(movie.has_file.to_string()) - ]).style(row_style)); - let header_row = Row::new(vec!["Title", "Year", "Monitored", "Downloaded"]) - .style(Style::default().fg(Color::White)) - .bottom_margin(0); - let constraints = vec![ - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Percentage(25), - ]; - - let table = Table::new(rows) - .header(header_row) - .block(block) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) - .highlight_symbol(HIGHLIGHT_SYMBOL) - .widths(&constraints); - - f.render_stateful_widget(table, area, &mut app.data.radarr_data.movies.state) -} - -fn draw_stats(f: &mut Frame<'_, B>, app: &App, area: Rect) { - let RadarrData { - free_space, - total_space, - start_time, - .. - } = app.data.radarr_data; - let ratio = if total_space == 0 { - 0f64 - } else { - 1f64 - (free_space as f64 / total_space as f64) - }; - - let base_block = Block::default().title("Stats").borders(Borders::ALL); - f.render_widget(base_block, area); - - let chunks = - vertical_chunks_with_margin(vec![ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(2)], area, 1); - - let version_paragraph = Paragraph::new(Text::from(format!( - "Radarr Version: {}", - app.data.radarr_data.version - ))) - .block(Block::default()); - - let uptime = Utc::now().sub(start_time); - let days = uptime.num_days(); - let day_difference = uptime.sub(Duration::days(days)); - let hours = day_difference.num_hours(); - let hour_difference = day_difference.sub(Duration::hours(hours)); - let minutes = hour_difference.num_minutes(); - let seconds = hour_difference.sub(Duration::minutes(minutes)).num_seconds(); - - let uptime_paragraph = Paragraph::new(Text::from(format!( - "Uptime: {}d {:0width$}:{:0width$}:{:0width$}", - days, hours, minutes, seconds, width = 2 - ))).block(Block::default()); - - let space_gauge = LineGauge::default() - .block(Block::default().title("Storage:")) - .gauge_style(Style::default().fg(Color::Cyan)) - .line_set(symbols::line::THICK) - .ratio(ratio) - .label(Spans::from(format!("{:.0}%", ratio * 100.0))); - - f.render_widget(version_paragraph, chunks[0]); - f.render_widget(uptime_paragraph, chunks[1]); - f.render_widget(space_gauge, chunks[2]); -} - -fn draw_logo(f: &mut Frame<'_, B>, area: Rect) { - let chunks = vertical_chunks( - vec![Constraint::Percentage(60), Constraint::Percentage(40)], - area, - ); - let logo = Paragraph::new(Text::from(RADARR_LOGO)) - .block(Block::default()) - .alignment(Alignment::Center); - - f.render_widget(logo, chunks[0]); + radarr_ui::draw_logo(f, chunks[4]); } diff --git a/src/ui/radarr_ui.rs b/src/ui/radarr_ui.rs new file mode 100644 index 0000000..770f2b7 --- /dev/null +++ b/src/ui/radarr_ui.rs @@ -0,0 +1,138 @@ +use std::ops::Sub; + +use chrono::{Duration, Utc}; +use tui::{Frame, symbols}; +use tui::backend::Backend; +use tui::layout::{Alignment, Constraint, Rect}; +use tui::style::{Color, Modifier, Style}; +use tui::text::{Span, Spans, Text}; +use tui::widgets::{Block, Borders, Cell, LineGauge, Paragraph, Row, Table}; + +use crate::app::App; +use crate::app::radarr::RadarrData; +use crate::logos::RADARR_LOGO; +use crate::network::radarr_network::Movie; +use crate::ui::HIGHLIGHT_SYMBOL; +use crate::ui::utils::{style_default, style_highlight, style_primary, style_secondary, style_tertiary, title_block, vertical_chunks, vertical_chunks_with_margin}; + +pub(super) fn draw_radarr_ui(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { + let block = Block::default().borders(Borders::ALL).title(Spans::from(vec![ + Span::styled("Movies", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) + ])); + + let rows = app.data.radarr_data.movies.items + .iter() + .map(|movie| { + let runtime = movie.runtime.as_u64().unwrap(); + let hours = runtime / 60; + let minutes = runtime % 60; + let file_size: f64 = movie.size_on_disk.as_u64().unwrap() as f64 / 1024f64.powi(3); + + Row::new(vec![ + Cell::from(movie.title.to_owned()), + Cell::from(movie.year.to_string()), + Cell::from(format!("{:0width$}:{:0width$}", hours, minutes, width = 2)), + Cell::from(format!("{:.2} GB", file_size)), + Cell::from(app.data.radarr_data.quality_profile_map.get(&movie.quality_profile_id.as_u64().unwrap()).unwrap().to_owned()) + ]).style(determine_row_style(app, movie)) + }); + let header_row = Row::new(vec!["Title", "Year", "Runtime", "Size", "Quality Profile"]) + .style(style_default()) + .bottom_margin(0); + let constraints = vec![ + Constraint::Percentage(20), + Constraint::Percentage(20), + Constraint::Percentage(20), + Constraint::Percentage(20), + Constraint::Percentage(20), + ]; + + let table = Table::new(rows) + .header(header_row) + .block(block) + .highlight_style(style_highlight()) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .widths(&constraints); + + f.render_stateful_widget(table, area, &mut app.data.radarr_data.movies.state) +} + +pub(super) fn draw_stats(f: &mut Frame<'_, B>, app: &App, area: Rect) { + let RadarrData { + free_space, + total_space, + start_time, + .. + } = app.data.radarr_data; + let ratio = if total_space == 0 { + 0f64 + } else { + 1f64 - (free_space as f64 / total_space as f64) + }; + + f.render_widget(title_block("Stats"), area); + + let chunks = + vertical_chunks_with_margin(vec![ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(2)], area, 1); + + let version_paragraph = Paragraph::new(Text::from(format!( + "Radarr Version: {}", + app.data.radarr_data.version + ))).block(Block::default()); + + let uptime = Utc::now().sub(start_time); + let days = uptime.num_days(); + let day_difference = uptime.sub(Duration::days(days)); + let hours = day_difference.num_hours(); + let hour_difference = day_difference.sub(Duration::hours(hours)); + let minutes = hour_difference.num_minutes(); + let seconds = hour_difference.sub(Duration::minutes(minutes)).num_seconds(); + + let uptime_paragraph = Paragraph::new(Text::from(format!( + "Uptime: {}d {:0width$}:{:0width$}:{:0width$}", + days, hours, minutes, seconds, width = 2 + ))).block(Block::default()); + + let space_gauge = LineGauge::default() + .block(Block::default().title("Storage:")) + .gauge_style(Style::default().fg(Color::Cyan)) + .line_set(symbols::line::THICK) + .ratio(ratio) + .label(Spans::from(format!("{:.0}%", ratio * 100.0))); + + f.render_widget(version_paragraph, chunks[0]); + f.render_widget(uptime_paragraph, chunks[1]); + f.render_widget(space_gauge, chunks[2]); +} + +pub(super) fn draw_logo(f: &mut Frame<'_, B>, area: Rect) { + let chunks = vertical_chunks( + vec![Constraint::Percentage(60), Constraint::Percentage(40)], + area, + ); + let logo = Paragraph::new(Text::from(RADARR_LOGO)) + .block(Block::default()) + .alignment(Alignment::Center); + + f.render_widget(logo, chunks[0]); +} + +fn determine_row_style(app: &App, movie: &Movie) -> Style { + let downloads_vec = &app.data.radarr_data.downloads.items; + + if !movie.has_file { + if let Some(download) = downloads_vec.iter() + .find(|&download | download.movie_id == movie.id) { + if download.status == "downloading" { + return style_secondary() + } + } + + return style_tertiary() + } + + style_primary() +} \ No newline at end of file diff --git a/src/ui/utils.rs b/src/ui/utils.rs index e06cd40..6186f64 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -1,4 +1,7 @@ use tui::layout::{Constraint, Direction, Layout, Rect}; +use tui::style::{Color, Modifier, Style}; +use tui::text::Span; +use tui::widgets::{Block, Borders}; pub fn horizontal_chunks(constraints: Vec, size: Rect) -> Vec { Layout::default() @@ -45,3 +48,39 @@ pub fn vertical_chunks_with_margin( .margin(margin) .split(size) } + +pub fn layout_block(title_span: Span<'_>) -> Block<'_> { + Block::default().borders(Borders::ALL).title(title_span) +} + +pub fn style_bold() -> Style { + Style::default().add_modifier(Modifier::BOLD) +} + +pub fn style_highlight() -> Style { + Style::default().add_modifier(Modifier::REVERSED) +} + +pub fn style_default() -> Style { + Style::default().fg(Color::White) +} + +pub fn style_primary() -> Style { + Style::default().fg(Color::Green) +} + +pub fn style_secondary() -> Style { + Style::default().fg(Color::Magenta) +} + +pub fn style_tertiary() -> Style { + Style::default().fg(Color::Red) +} + +pub fn title_style(title: &str) -> Span<'_> { + Span::styled(title, style_bold()) +} + +pub fn title_block(title: &str) -> Block<'_> { + layout_block(title_style(title)) +}