From 5cb60c317de635720eeaa6d9103b1a4dfc4b11a5 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 4 Mar 2025 18:09:09 -0700 Subject: [PATCH] feat: Initial support for custom user-defined themes --- src/app/app_tests.rs | 1 + src/app/mod.rs | 1 + src/main.rs | 69 ++++++++++-- src/ui/mod.rs | 6 ++ src/ui/styles.rs | 36 +++---- src/ui/styles_tests.rs | 11 +- src/ui/theme.rs | 233 +++++++++++++++++++++++++++++++++++++++++ src/ui/theme_tests.rs | 191 +++++++++++++++++++++++++++++++++ src/ui/utils.rs | 26 +++-- src/ui/utils_tests.rs | 45 ++++---- src/utils.rs | 30 +++++- 11 files changed, 582 insertions(+), 67 deletions(-) create mode 100644 src/ui/theme.rs create mode 100644 src/ui/theme_tests.rs diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index b0b6101..6c2e921 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -31,6 +31,7 @@ mod tests { }; let sonarr_config_2 = ServarrConfig::default(); let config = AppConfig { + theme: None, radarr: Some(vec![radarr_config_1.clone(), radarr_config_2.clone()]), sonarr: Some(vec![sonarr_config_1.clone(), sonarr_config_2.clone()]), }; diff --git a/src/app/mod.rs b/src/app/mod.rs index d4f7b42..7ea638b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -270,6 +270,7 @@ pub struct Data<'a> { #[derive(Debug, Deserialize, Serialize, Default, Clone)] pub struct AppConfig { + pub theme: Option, pub radarr: Option>, pub sonarr: Option>, } diff --git a/src/main.rs b/src/main.rs index c6f1417..cf6b2b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,4 @@ use anyhow::Result; -use std::panic::PanicHookInfo; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::{io, panic, process}; - use clap::{crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser}; use clap_complete::generate; use crossterm::execute; @@ -16,6 +10,11 @@ use network::NetworkTrait; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use reqwest::Client; +use std::panic::PanicHookInfo; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::{io, panic, process}; use tokio::select; use tokio::sync::mpsc::Receiver; use tokio::sync::{mpsc, Mutex}; @@ -24,12 +23,14 @@ use utils::{ build_network_client, load_config, start_cli_no_spinner, start_cli_with_spinner, tail_logs, }; -use crate::app::App; +use crate::app::{log_and_print_error, App}; use crate::cli::Command; use crate::event::input_event::{Events, InputEvent}; use crate::event::Key; use crate::network::{Network, NetworkEvent}; -use crate::ui::ui; +use crate::ui::theme::{Theme, ThemeDefinition}; +use crate::ui::{ui, THEME}; +use crate::utils::load_theme_config; mod app; mod cli; @@ -74,6 +75,22 @@ struct Cli { help = "The Managarr configuration file to use" )] config_file: Option, + #[arg( + long, + global = true, + value_parser, + env = "MANAGARR_THEME_FILE", + help = "The Managarr theme file to use" + )] + theme_file: Option, + #[arg( + long, + global = true, + value_parser, + env = "MANAGARR_THEME", + help = "The name of the Managarr theme to use" + )] + theme: Option, #[arg( long, global = true, @@ -98,10 +115,12 @@ async fn main() -> Result<()> { } else { confy::load("managarr", "config")? }; + let theme_name = config.theme.clone(); let spinner_disabled = args.disable_spinner; debug!("Managarr loaded using config: {config:?}"); config.validate(); config.post_process_initialization(); + let reqwest_client = build_network_client(&config); let (sync_network_tx, sync_network_rx) = mpsc::channel(500); let cancellation_token = CancellationToken::new(); @@ -140,7 +159,12 @@ async fn main() -> Result<()> { std::thread::spawn(move || { start_networking(sync_network_rx, &app_nw, cancellation_token, reqwest_client) }); - start_ui(&app).await?; + start_ui( + &app, + &args.theme_file, + args.theme.unwrap_or(theme_name.unwrap_or_default()), + ) + .await?; } } @@ -174,7 +198,32 @@ async fn start_networking( } } -async fn start_ui(app: &Arc>>) -> Result<()> { +async fn start_ui( + app: &Arc>>, + theme_file_arg: &Option, + theme_name: String, +) -> Result<()> { + let theme_definitions = if let Some(ref theme_file) = theme_file_arg { + load_theme_config(theme_file.to_str().expect("Invalid theme file specified"))? + } else { + confy::load("managarr", "theme").unwrap_or_else(|_| vec![ThemeDefinition::default()]) + }; + let theme = if !theme_name.is_empty() { + let theme_definition = theme_definitions.iter().find(|t| t.name == theme_name); + + if theme_definition.is_none() { + log_and_print_error(format!("The specified theme was not found: {theme_name}")); + process::exit(1); + } + + theme_definition.unwrap().theme + } else { + debug!("No theme specified, using default theme"); + Theme::default() + }; + debug!("Managarr loaded using theme: {theme:?}"); + THEME.set(theme); + let mut stdout = io::stdout(); enable_raw_mode()?; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index aa5d0ae..23ad13b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +use std::cell::Cell; use std::sync::atomic::Ordering; use ratatui::layout::{Constraint, Flex, Layout, Rect}; @@ -15,6 +16,7 @@ use crate::app::App; use crate::models::{HorizontallyScrollableText, Route, TabState}; use crate::ui::radarr_ui::RadarrUi; use crate::ui::styles::ManagarrStyle; +use crate::ui::theme::Theme; use crate::ui::utils::{ background_block, borderless_block, centered_rect, logo_block, title_block, title_block_centered, }; @@ -24,10 +26,14 @@ use crate::ui::widgets::popup::Size; mod radarr_ui; mod sonarr_ui; mod styles; +pub mod theme; mod utils; mod widgets; static HIGHLIGHT_SYMBOL: &str = "=> "; +thread_local! { + pub static THEME: Cell = Cell::new(Theme::default()); +} pub trait DrawUi { fn accepts(route: Route) -> bool; diff --git a/src/ui/styles.rs b/src/ui/styles.rs index 86f7233..3bc4ef6 100644 --- a/src/ui/styles.rs +++ b/src/ui/styles.rs @@ -1,8 +1,6 @@ -use ratatui::prelude::Color; +use crate::ui::THEME; use ratatui::style::{Styled, Stylize}; -pub const COLOR_ORANGE: Color = Color::Rgb(255, 170, 66); - #[cfg(test)] #[path = "styles_tests.rs"] mod styles_tests; @@ -42,31 +40,31 @@ where } fn awaiting_import(self) -> T { - self.fg(COLOR_ORANGE) + THEME.with(|theme| self.fg(theme.get().awaiting_import.unwrap().color.unwrap())) } fn indeterminate(self) -> T { - self.fg(COLOR_ORANGE) + THEME.with(|theme| self.fg(theme.get().indeterminate.unwrap().color.unwrap())) } fn default(self) -> T { - self.white() + THEME.with(|theme| self.fg(theme.get().default.unwrap().color.unwrap())) } fn downloaded(self) -> T { - self.green() + THEME.with(|theme| self.fg(theme.get().downloaded.unwrap().color.unwrap())) } fn downloading(self) -> T { - self.magenta() + THEME.with(|theme| self.fg(theme.get().downloading.unwrap().color.unwrap())) } fn failure(self) -> T { - self.red() + THEME.with(|theme| self.fg(theme.get().failure.unwrap().color.unwrap())) } fn help(self) -> T { - self.light_blue() + THEME.with(|theme| self.fg(theme.get().help.unwrap().color.unwrap())) } fn highlight(self) -> T { @@ -74,38 +72,38 @@ where } fn missing(self) -> T { - self.red() + THEME.with(|theme| self.fg(theme.get().missing.unwrap().color.unwrap())) } fn primary(self) -> T { - self.cyan() + THEME.with(|theme| self.fg(theme.get().primary.unwrap().color.unwrap())) } fn secondary(self) -> T { - self.yellow() + THEME.with(|theme| self.fg(theme.get().secondary.unwrap().color.unwrap())) } fn success(self) -> T { - self.green() + THEME.with(|theme| self.fg(theme.get().success.unwrap().color.unwrap())) } fn system_function(self) -> T { - self.yellow() + THEME.with(|theme| self.fg(theme.get().system_function.unwrap().color.unwrap())) } fn unmonitored(self) -> T { - self.gray() + THEME.with(|theme| self.fg(theme.get().unmonitored.unwrap().color.unwrap())) } fn unmonitored_missing(self) -> T { - self.yellow() + THEME.with(|theme| self.fg(theme.get().unmonitored_missing.unwrap().color.unwrap())) } fn unreleased(self) -> T { - self.light_cyan() + THEME.with(|theme| self.fg(theme.get().unreleased.unwrap().color.unwrap())) } fn warning(self) -> T { - self.magenta() + THEME.with(|theme| self.fg(theme.get().warning.unwrap().color.unwrap())) } } diff --git a/src/ui/styles_tests.rs b/src/ui/styles_tests.rs index 98259ec..2ec748f 100644 --- a/src/ui/styles_tests.rs +++ b/src/ui/styles_tests.rs @@ -1,9 +1,9 @@ #[cfg(test)] mod test { - use crate::ui::styles::{ManagarrStyle, COLOR_ORANGE}; + use crate::ui::styles::ManagarrStyle; use pretty_assertions::assert_eq; use ratatui::prelude::Modifier; - use ratatui::style::{Style, Stylize}; + use ratatui::style::{Color, Style, Stylize}; #[test] fn test_new() { @@ -14,13 +14,16 @@ mod test { fn test_style_awaiting_import() { assert_eq!( Style::new().awaiting_import(), - Style::new().fg(COLOR_ORANGE) + Style::new().fg(Color::Rgb(255, 170, 66)) ); } #[test] fn test_style_indeterminate() { - assert_eq!(Style::new().indeterminate(), Style::new().fg(COLOR_ORANGE)); + assert_eq!( + Style::new().indeterminate(), + Style::new().fg(Color::Rgb(255, 170, 66)) + ); } #[test] diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..ad2ff5d --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,233 @@ +use anyhow::Result; +use derivative::Derivative; +use ratatui::style::Color; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[cfg(test)] +#[path = "theme_tests.rs"] +mod theme_tests; + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, Derivative)] +#[derivative(Default)] +#[cfg_attr(test, derive(PartialEq, Eq))] +pub struct Background { + #[serde( + deserialize_with = "deserialize_color_str", + serialize_with = "serialize_color_str", + default = "default_background_color" + )] + #[derivative(Default(value = "Some(Color::Rgb(35, 50, 55))"))] + pub color: Option, + #[derivative(Default(value = "Some(true)"))] + #[serde(default = "default_background_enabled")] + pub enabled: Option, +} + +#[derive(Debug, Deserialize, Serialize, Default, Clone, Copy)] +#[cfg_attr(test, derive(PartialEq, Eq))] +pub struct Style { + #[serde( + deserialize_with = "deserialize_color_str", + serialize_with = "serialize_color_str" + )] + pub color: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, Derivative)] +#[derivative(Default)] +#[cfg_attr(test, derive(PartialEq, Eq))] +pub struct Theme { + #[serde(default = "default_background")] + #[derivative(Default( + value = "Some(Background { color: Some(Color::Rgb(35, 50, 55)), enabled: Some(true) })" + ))] + pub background: Option, + #[serde(default = "default_awaiting_import_style")] + #[derivative(Default(value = "Some(Style { color: Some(Color::Rgb(255, 170, 66)) })"))] + pub awaiting_import: Option