feat: Write built in themes to the themes file on first run so users can define custom themes
@@ -206,6 +206,9 @@ Key:
|
|||||||
|
|
||||||
- [ ] Support for Tautulli
|
- [ ] Support for Tautulli
|
||||||
|
|
||||||
|
### Themes
|
||||||
|
Managarr ships with a few themes out of the box. See the [Themes README](themes/README.md) page for more information.
|
||||||
|
|
||||||
### The Managarr CLI
|
### The Managarr CLI
|
||||||
Managarr can be used in one of two ways: As a TUI, or as a CLI for managing your Servarrs.
|
Managarr can be used in one of two ways: As a TUI, or as a CLI for managing your Servarrs.
|
||||||
|
|
||||||
@@ -315,6 +318,7 @@ managarr --config-file /path/to/config.yml
|
|||||||
|
|
||||||
### Example Configuration:
|
### Example Configuration:
|
||||||
```yaml
|
```yaml
|
||||||
|
theme: default
|
||||||
radarr:
|
radarr:
|
||||||
- host: 192.168.0.78
|
- host: 192.168.0.78
|
||||||
port: 7878
|
port: 7878
|
||||||
@@ -357,6 +361,7 @@ tautulli:
|
|||||||
|
|
||||||
### Example Multi-Instance Configuration:
|
### Example Multi-Instance Configuration:
|
||||||
```yaml
|
```yaml
|
||||||
|
theme: default
|
||||||
radarr:
|
radarr:
|
||||||
- host: 192.168.0.78 # No name specified, so this instance's name will default to 'Radarr 1'
|
- host: 192.168.0.78 # No name specified, so this instance's name will default to 'Radarr 1'
|
||||||
port: 7878
|
port: 7878
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
use syn::{parse_macro_input, Data, DeriveInput, Fields};
|
use syn::{Data, DeriveInput, Fields, parse_macro_input};
|
||||||
|
|
||||||
/// Derive macro for generating a `validate` method for a Theme struct.
|
/// Derive macro for generating a `validate` method for a Theme struct.
|
||||||
/// The `validate` method ensures that all values with the `validate` attribute are not `None`.
|
/// The `validate` method ensures that all values with the `validate` attribute are not `None`.
|
||||||
/// Otherwise, an error message it output to both the log file and stdout and the program exits.
|
/// Otherwise, an error message it output to both the log file and stdout and the program exits.
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// Valid themes pass through the program transitively without any messages being output.
|
/// Valid themes pass through the program transitively without any messages being output.
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use validate_theme_derive::ValidateTheme;
|
/// use validate_theme_derive::ValidateTheme;
|
||||||
///
|
///
|
||||||
/// #[derive(ValidateTheme, Default)]
|
/// #[derive(ValidateTheme, Default)]
|
||||||
/// struct Theme {
|
/// struct Theme {
|
||||||
/// pub name: String,
|
/// pub name: String,
|
||||||
@@ -22,22 +22,22 @@ use syn::{parse_macro_input, Data, DeriveInput, Fields};
|
|||||||
/// pub bad: Option<Style>,
|
/// pub bad: Option<Style>,
|
||||||
/// pub ugly: Option<Style>,
|
/// pub ugly: Option<Style>,
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// struct Style {
|
/// struct Style {
|
||||||
/// color: String,
|
/// color: String,
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// let theme = Theme {
|
/// let theme = Theme {
|
||||||
/// good: Some(Style { color: "Green".to_owned() }),
|
/// good: Some(Style { color: "Green".to_owned() }),
|
||||||
/// bad: Some(Style { color: "Red".to_owned() }),
|
/// bad: Some(Style { color: "Red".to_owned() }),
|
||||||
/// ..Theme::default()
|
/// ..Theme::default()
|
||||||
/// };
|
/// };
|
||||||
///
|
///
|
||||||
/// // Since only `good` and `bad` have the `validate` attribute, the `validate` method will only check those fields.
|
/// // Since only `good` and `bad` have the `validate` attribute, the `validate` method will only check those fields.
|
||||||
/// theme.validate();
|
/// theme.validate();
|
||||||
/// // Since both `good` and `bad` have values, the program will not exit and no message is output.
|
/// // Since both `good` and `bad` have values, the program will not exit and no message is output.
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// Invalid themes will output an error message to both the log file and stdout and the program will exit.
|
/// Invalid themes will output an error message to both the log file and stdout and the program will exit.
|
||||||
///
|
///
|
||||||
/// ```should_panic
|
/// ```should_panic
|
||||||
@@ -61,44 +61,46 @@ use syn::{parse_macro_input, Data, DeriveInput, Fields};
|
|||||||
/// bad: Some(Style { color: "Red".to_owned() }),
|
/// bad: Some(Style { color: "Red".to_owned() }),
|
||||||
/// ..Theme::default()
|
/// ..Theme::default()
|
||||||
/// };
|
/// };
|
||||||
///
|
///
|
||||||
/// // Since `good` has the `validate` attribute and since `good` is `None`, the `validate` method will output an error message and exit the program.
|
/// // Since `good` has the `validate` attribute and since `good` is `None`, the `validate` method will output an error message and exit the program.
|
||||||
/// theme.validate();
|
/// theme.validate();
|
||||||
/// ```
|
/// ```
|
||||||
#[proc_macro_derive(ValidateTheme, attributes(validate))]
|
#[proc_macro_derive(ValidateTheme, attributes(validate))]
|
||||||
pub fn derive_validate_theme(input: TokenStream) -> TokenStream {
|
pub fn derive_validate_theme(input: TokenStream) -> TokenStream {
|
||||||
let input = parse_macro_input!(input as DeriveInput);
|
let input = parse_macro_input!(input as DeriveInput);
|
||||||
let struct_name = &input.ident;
|
let struct_name = &input.ident;
|
||||||
|
|
||||||
let mut validation_checks = Vec::new();
|
let mut validation_checks = Vec::new();
|
||||||
|
|
||||||
if let Data::Struct(data_struct) = &input.data {
|
if let Data::Struct(data_struct) = &input.data {
|
||||||
if let Fields::Named(fields) = &data_struct.fields {
|
if let Fields::Named(fields) = &data_struct.fields {
|
||||||
for field in &fields.named {
|
for field in &fields.named {
|
||||||
let field_name = &field.ident;
|
let field_name = &field.ident;
|
||||||
|
|
||||||
let has_validate_attr = field.attrs.iter().any(|attr| {
|
let has_validate_attr = field
|
||||||
attr.path().is_ident("validate")
|
.attrs
|
||||||
});
|
.iter()
|
||||||
|
.any(|attr| attr.path().is_ident("validate"));
|
||||||
|
|
||||||
if has_validate_attr {
|
if has_validate_attr {
|
||||||
validation_checks.push(quote! {
|
validation_checks.push(quote! {
|
||||||
if self.#field_name.is_none() {
|
if self.#field_name.is_none() {
|
||||||
log::error!("{} is missing a color value.", stringify!(#field_name));
|
log::error!("{} is missing a color value.", stringify!(#field_name));
|
||||||
eprintln!("{} is missing a color value.", stringify!(#field_name));
|
eprintln!("{} is missing a color value.", stringify!(#field_name));
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
impl #struct_name {
|
impl #struct_name {
|
||||||
pub fn validate(&self) {
|
pub fn validate(&self) {
|
||||||
#(#validation_checks)*
|
#(#validation_checks)*
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.into()
|
}
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use crate::ui::theme::{Background, Style, Theme, ThemeDefinition};
|
||||||
|
use ratatui::style::Color;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "builtin_themes_tests.rs"]
|
||||||
|
mod builtin_themes_tests;
|
||||||
|
|
||||||
|
pub fn get_builtin_themes() -> Vec<ThemeDefinition> {
|
||||||
|
let watermelon_dark = Theme {
|
||||||
|
background: Some(Background {
|
||||||
|
enabled: Some(false),
|
||||||
|
color: Some(Color::from_str("#233237").unwrap()),
|
||||||
|
}),
|
||||||
|
default: Some(Style {
|
||||||
|
color: Some(Color::from_str("#00FF00").unwrap()),
|
||||||
|
}),
|
||||||
|
downloaded: Some(Style {
|
||||||
|
color: Some(Color::from_str("#80ffbf").unwrap()),
|
||||||
|
}),
|
||||||
|
failure: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff8080").unwrap()),
|
||||||
|
}),
|
||||||
|
missing: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff8080").unwrap()),
|
||||||
|
}),
|
||||||
|
primary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff19d9").unwrap()),
|
||||||
|
}),
|
||||||
|
secondary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#8c19ff").unwrap()),
|
||||||
|
}),
|
||||||
|
..Theme::default()
|
||||||
|
};
|
||||||
|
let dracula = Theme {
|
||||||
|
background: Some(Background {
|
||||||
|
enabled: Some(false),
|
||||||
|
color: Some(Color::from_str("#233237").unwrap()),
|
||||||
|
}),
|
||||||
|
default: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f8f8f2").unwrap()),
|
||||||
|
}),
|
||||||
|
downloaded: Some(Style {
|
||||||
|
color: Some(Color::from_str("#50fa7b").unwrap()),
|
||||||
|
}),
|
||||||
|
downloading: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f1fa8c").unwrap()),
|
||||||
|
}),
|
||||||
|
failure: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff5555").unwrap()),
|
||||||
|
}),
|
||||||
|
missing: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ffb86c").unwrap()),
|
||||||
|
}),
|
||||||
|
primary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff79c6").unwrap()),
|
||||||
|
}),
|
||||||
|
secondary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff79c6").unwrap()),
|
||||||
|
}),
|
||||||
|
unmonitored_missing: Some(Style {
|
||||||
|
color: Some(Color::from_str("#6272a4").unwrap()),
|
||||||
|
}),
|
||||||
|
help: Some(Style {
|
||||||
|
color: Some(Color::from_str("#6272a4").unwrap()),
|
||||||
|
}),
|
||||||
|
success: Some(Style {
|
||||||
|
color: Some(Color::from_str("#50fa7b").unwrap()),
|
||||||
|
}),
|
||||||
|
warning: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f1fa8c").unwrap()),
|
||||||
|
}),
|
||||||
|
unreleased: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f8f8f2").unwrap()),
|
||||||
|
}),
|
||||||
|
..Theme::default()
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
ThemeDefinition {
|
||||||
|
name: "default".to_owned(),
|
||||||
|
theme: Theme::default(),
|
||||||
|
},
|
||||||
|
ThemeDefinition {
|
||||||
|
name: "watermelon-dark".to_owned(),
|
||||||
|
theme: watermelon_dark,
|
||||||
|
},
|
||||||
|
ThemeDefinition {
|
||||||
|
name: "dracula".to_owned(),
|
||||||
|
theme: dracula,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use crate::builtin_themes::get_builtin_themes;
|
||||||
|
use crate::ui::theme::{Background, Style, Theme, ThemeDefinition};
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use ratatui::prelude::Color;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_builtin_themes() {
|
||||||
|
let watermelon_dark = Theme {
|
||||||
|
background: Some(Background {
|
||||||
|
enabled: Some(false),
|
||||||
|
color: Some(Color::from_str("#233237").unwrap()),
|
||||||
|
}),
|
||||||
|
default: Some(Style {
|
||||||
|
color: Some(Color::from_str("#00FF00").unwrap()),
|
||||||
|
}),
|
||||||
|
downloaded: Some(Style {
|
||||||
|
color: Some(Color::from_str("#80ffbf").unwrap()),
|
||||||
|
}),
|
||||||
|
failure: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff8080").unwrap()),
|
||||||
|
}),
|
||||||
|
missing: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff8080").unwrap()),
|
||||||
|
}),
|
||||||
|
primary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff19d9").unwrap()),
|
||||||
|
}),
|
||||||
|
secondary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#8c19ff").unwrap()),
|
||||||
|
}),
|
||||||
|
..Theme::default()
|
||||||
|
};
|
||||||
|
let dracula = Theme {
|
||||||
|
background: Some(Background {
|
||||||
|
enabled: Some(false),
|
||||||
|
color: Some(Color::from_str("#233237").unwrap()),
|
||||||
|
}),
|
||||||
|
default: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f8f8f2").unwrap()),
|
||||||
|
}),
|
||||||
|
downloaded: Some(Style {
|
||||||
|
color: Some(Color::from_str("#50fa7b").unwrap()),
|
||||||
|
}),
|
||||||
|
downloading: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f1fa8c").unwrap()),
|
||||||
|
}),
|
||||||
|
failure: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff5555").unwrap()),
|
||||||
|
}),
|
||||||
|
missing: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ffb86c").unwrap()),
|
||||||
|
}),
|
||||||
|
primary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff79c6").unwrap()),
|
||||||
|
}),
|
||||||
|
secondary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff79c6").unwrap()),
|
||||||
|
}),
|
||||||
|
unmonitored_missing: Some(Style {
|
||||||
|
color: Some(Color::from_str("#6272a4").unwrap()),
|
||||||
|
}),
|
||||||
|
help: Some(Style {
|
||||||
|
color: Some(Color::from_str("#6272a4").unwrap()),
|
||||||
|
}),
|
||||||
|
success: Some(Style {
|
||||||
|
color: Some(Color::from_str("#50fa7b").unwrap()),
|
||||||
|
}),
|
||||||
|
warning: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f1fa8c").unwrap()),
|
||||||
|
}),
|
||||||
|
unreleased: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f8f8f2").unwrap()),
|
||||||
|
}),
|
||||||
|
..Theme::default()
|
||||||
|
};
|
||||||
|
let expected_themes = vec![
|
||||||
|
ThemeDefinition {
|
||||||
|
name: "default".to_owned(),
|
||||||
|
theme: Theme::default(),
|
||||||
|
},
|
||||||
|
ThemeDefinition {
|
||||||
|
name: "watermelon-dark".to_owned(),
|
||||||
|
theme: watermelon_dark,
|
||||||
|
},
|
||||||
|
ThemeDefinition {
|
||||||
|
name: "dracula".to_owned(),
|
||||||
|
theme: dracula,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(expected_themes, get_builtin_themes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,11 +28,13 @@ use crate::cli::Command;
|
|||||||
use crate::event::input_event::{Events, InputEvent};
|
use crate::event::input_event::{Events, InputEvent};
|
||||||
use crate::event::Key;
|
use crate::event::Key;
|
||||||
use crate::network::{Network, NetworkEvent};
|
use crate::network::{Network, NetworkEvent};
|
||||||
use crate::ui::theme::{Theme, ThemeDefinition};
|
use crate::ui::theme::{Theme, ThemeDefinitionsWrapper};
|
||||||
use crate::ui::{ui, THEME};
|
use crate::ui::{ui, THEME};
|
||||||
use crate::utils::load_theme_config;
|
use crate::utils::load_theme_config;
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
|
mod builtin_themes;
|
||||||
|
mod builtin_themes_tests;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod event;
|
mod event;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
@@ -80,9 +82,9 @@ struct Cli {
|
|||||||
global = true,
|
global = true,
|
||||||
value_parser,
|
value_parser,
|
||||||
env = "MANAGARR_THEME_FILE",
|
env = "MANAGARR_THEME_FILE",
|
||||||
help = "The Managarr theme file to use"
|
help = "The Managarr themes file to use"
|
||||||
)]
|
)]
|
||||||
theme_file: Option<PathBuf>,
|
themes_file: Option<PathBuf>,
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
global = true,
|
global = true,
|
||||||
@@ -161,7 +163,7 @@ async fn main() -> Result<()> {
|
|||||||
});
|
});
|
||||||
start_ui(
|
start_ui(
|
||||||
&app,
|
&app,
|
||||||
&args.theme_file,
|
&args.themes_file,
|
||||||
args.theme.unwrap_or(theme_name.unwrap_or_default()),
|
args.theme.unwrap_or(theme_name.unwrap_or_default()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -200,16 +202,19 @@ async fn start_networking(
|
|||||||
|
|
||||||
async fn start_ui(
|
async fn start_ui(
|
||||||
app: &Arc<Mutex<App<'_>>>,
|
app: &Arc<Mutex<App<'_>>>,
|
||||||
theme_file_arg: &Option<PathBuf>,
|
themes_file_arg: &Option<PathBuf>,
|
||||||
theme_name: String,
|
theme_name: String,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let theme_definitions = if let Some(ref theme_file) = theme_file_arg {
|
let theme_definitions_wrapper = if let Some(ref theme_file) = themes_file_arg {
|
||||||
load_theme_config(theme_file.to_str().expect("Invalid theme file specified"))?
|
load_theme_config(theme_file.to_str().expect("Invalid theme file specified"))?
|
||||||
} else {
|
} else {
|
||||||
confy::load("managarr", "themes").unwrap_or_else(|_| vec![ThemeDefinition::default()])
|
confy::load("managarr", "themes").unwrap_or_else(|_| ThemeDefinitionsWrapper::default())
|
||||||
};
|
};
|
||||||
let theme = if !theme_name.is_empty() {
|
let theme = if !theme_name.is_empty() {
|
||||||
let theme_definition = theme_definitions.iter().find(|t| t.name == theme_name);
|
let theme_definition = theme_definitions_wrapper
|
||||||
|
.theme_definitions
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.name == theme_name);
|
||||||
|
|
||||||
if theme_definition.is_none() {
|
if theme_definition.is_none() {
|
||||||
log_and_print_error(format!("The specified theme was not found: {theme_name}"));
|
log_and_print_error(format!("The specified theme was not found: {theme_name}"));
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
use crate::builtin_themes::get_builtin_themes;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use derivative::Derivative;
|
use derivative::Derivative;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use validate_theme_derive::ValidateTheme;
|
use validate_theme_derive::ValidateTheme;
|
||||||
|
|
||||||
@@ -118,6 +119,20 @@ pub struct ThemeDefinition {
|
|||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cfg_attr(test, derive(PartialEq, Eq))]
|
||||||
|
pub struct ThemeDefinitionsWrapper {
|
||||||
|
pub theme_definitions: Vec<ThemeDefinition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ThemeDefinitionsWrapper {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
theme_definitions: get_builtin_themes(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn default_background_color() -> Option<Color> {
|
fn default_background_color() -> Option<Color> {
|
||||||
Some(Color::Rgb(35, 50, 55))
|
Some(Color::Rgb(35, 50, 55))
|
||||||
}
|
}
|
||||||
@@ -229,9 +244,28 @@ fn default_warning_style() -> Option<Style> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for ThemeDefinitionsWrapper {
|
||||||
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let theme_definitions = Vec::<ThemeDefinition>::deserialize(deserializer)?;
|
||||||
|
Ok(ThemeDefinitionsWrapper { theme_definitions })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for ThemeDefinitionsWrapper {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
self.theme_definitions.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn deserialize_color_str<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
|
fn deserialize_color_str<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
|
||||||
where
|
where
|
||||||
D: serde::Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
{
|
{
|
||||||
let s: Option<String> = Option::deserialize(deserializer)?;
|
let s: Option<String> = Option::deserialize(deserializer)?;
|
||||||
match s {
|
match s {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
mod tests {
|
mod tests {
|
||||||
use crate::ui::theme::{Background, Style, Theme, ThemeDefinition};
|
use crate::ui::theme::{Background, Style, Theme, ThemeDefinition, ThemeDefinitionsWrapper};
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::{assert_eq, assert_str_eq};
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_background_default() {
|
fn test_background_default() {
|
||||||
@@ -188,4 +189,252 @@ warning:
|
|||||||
|
|
||||||
assert_eq!(theme, expected_theme);
|
assert_eq!(theme, expected_theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_theme_definitions_wrapper_default() {
|
||||||
|
let watermelon_dark = Theme {
|
||||||
|
background: Some(Background {
|
||||||
|
enabled: Some(false),
|
||||||
|
color: Some(Color::from_str("#233237").unwrap()),
|
||||||
|
}),
|
||||||
|
default: Some(Style {
|
||||||
|
color: Some(Color::from_str("#00FF00").unwrap()),
|
||||||
|
}),
|
||||||
|
downloaded: Some(Style {
|
||||||
|
color: Some(Color::from_str("#80ffbf").unwrap()),
|
||||||
|
}),
|
||||||
|
failure: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff8080").unwrap()),
|
||||||
|
}),
|
||||||
|
missing: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff8080").unwrap()),
|
||||||
|
}),
|
||||||
|
primary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff19d9").unwrap()),
|
||||||
|
}),
|
||||||
|
secondary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#8c19ff").unwrap()),
|
||||||
|
}),
|
||||||
|
..Theme::default()
|
||||||
|
};
|
||||||
|
let dracula = Theme {
|
||||||
|
background: Some(Background {
|
||||||
|
enabled: Some(false),
|
||||||
|
color: Some(Color::from_str("#233237").unwrap()),
|
||||||
|
}),
|
||||||
|
default: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f8f8f2").unwrap()),
|
||||||
|
}),
|
||||||
|
downloaded: Some(Style {
|
||||||
|
color: Some(Color::from_str("#50fa7b").unwrap()),
|
||||||
|
}),
|
||||||
|
downloading: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f1fa8c").unwrap()),
|
||||||
|
}),
|
||||||
|
failure: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff5555").unwrap()),
|
||||||
|
}),
|
||||||
|
missing: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ffb86c").unwrap()),
|
||||||
|
}),
|
||||||
|
primary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff79c6").unwrap()),
|
||||||
|
}),
|
||||||
|
secondary: Some(Style {
|
||||||
|
color: Some(Color::from_str("#ff79c6").unwrap()),
|
||||||
|
}),
|
||||||
|
unmonitored_missing: Some(Style {
|
||||||
|
color: Some(Color::from_str("#6272a4").unwrap()),
|
||||||
|
}),
|
||||||
|
help: Some(Style {
|
||||||
|
color: Some(Color::from_str("#6272a4").unwrap()),
|
||||||
|
}),
|
||||||
|
success: Some(Style {
|
||||||
|
color: Some(Color::from_str("#50fa7b").unwrap()),
|
||||||
|
}),
|
||||||
|
warning: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f1fa8c").unwrap()),
|
||||||
|
}),
|
||||||
|
unreleased: Some(Style {
|
||||||
|
color: Some(Color::from_str("#f8f8f2").unwrap()),
|
||||||
|
}),
|
||||||
|
..Theme::default()
|
||||||
|
};
|
||||||
|
let theme_definitions_wrapper = ThemeDefinitionsWrapper {
|
||||||
|
theme_definitions: vec![
|
||||||
|
ThemeDefinition {
|
||||||
|
name: "default".to_owned(),
|
||||||
|
theme: Theme::default(),
|
||||||
|
},
|
||||||
|
ThemeDefinition {
|
||||||
|
name: "watermelon-dark".to_owned(),
|
||||||
|
theme: watermelon_dark,
|
||||||
|
},
|
||||||
|
ThemeDefinition {
|
||||||
|
name: "dracula".to_owned(),
|
||||||
|
theme: dracula,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ThemeDefinitionsWrapper::default(),
|
||||||
|
theme_definitions_wrapper
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_theme_definitions_wrapper_deserialization() {
|
||||||
|
let theme_definitions = r###"
|
||||||
|
- name: test
|
||||||
|
theme:
|
||||||
|
background:
|
||||||
|
enabled: false
|
||||||
|
color: "#000000"
|
||||||
|
awaiting_import:
|
||||||
|
color: "#000000"
|
||||||
|
indeterminate:
|
||||||
|
color: "#000000"
|
||||||
|
default:
|
||||||
|
color: "#000000"
|
||||||
|
downloaded:
|
||||||
|
color: "#000000"
|
||||||
|
downloading:
|
||||||
|
color: "#000000"
|
||||||
|
failure:
|
||||||
|
color: "#000000"
|
||||||
|
help:
|
||||||
|
color: "#000000"
|
||||||
|
missing:
|
||||||
|
color: "#000000"
|
||||||
|
primary:
|
||||||
|
color: "#000000"
|
||||||
|
secondary:
|
||||||
|
color: "#000000"
|
||||||
|
success:
|
||||||
|
color: "#000000"
|
||||||
|
system_function:
|
||||||
|
color: "#000000"
|
||||||
|
unmonitored:
|
||||||
|
color: "#000000"
|
||||||
|
unmonitored_missing:
|
||||||
|
color: "#000000"
|
||||||
|
unreleased:
|
||||||
|
color: "#000000"
|
||||||
|
warning:
|
||||||
|
color: "#000000"
|
||||||
|
"###;
|
||||||
|
let theme_definition_wrapper: ThemeDefinitionsWrapper =
|
||||||
|
serde_yaml::from_str(theme_definitions).unwrap();
|
||||||
|
let expected_theme_definitions = ThemeDefinitionsWrapper {
|
||||||
|
theme_definitions: vec![ThemeDefinition {
|
||||||
|
name: "test".to_owned(),
|
||||||
|
theme: Theme {
|
||||||
|
background: Some(Background {
|
||||||
|
enabled: Some(false),
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
awaiting_import: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
indeterminate: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
default: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
downloaded: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
downloading: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
failure: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
help: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
missing: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
primary: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
secondary: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
success: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
system_function: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
unmonitored: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
unmonitored_missing: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
unreleased: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
warning: Some(Style {
|
||||||
|
color: Some(Color::Rgb(0, 0, 0)),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(theme_definition_wrapper, expected_theme_definitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_theme_definition_wrapper_serialization() {
|
||||||
|
let theme_definition_wrapper = ThemeDefinitionsWrapper {
|
||||||
|
theme_definitions: vec![ThemeDefinition::default()],
|
||||||
|
};
|
||||||
|
let expected_yaml = r###"- name: ''
|
||||||
|
theme:
|
||||||
|
background:
|
||||||
|
color: '#233237'
|
||||||
|
enabled: true
|
||||||
|
awaiting_import:
|
||||||
|
color: '#FFAA42'
|
||||||
|
indeterminate:
|
||||||
|
color: '#FFAA42'
|
||||||
|
default:
|
||||||
|
color: White
|
||||||
|
downloaded:
|
||||||
|
color: Green
|
||||||
|
downloading:
|
||||||
|
color: Magenta
|
||||||
|
failure:
|
||||||
|
color: Red
|
||||||
|
help:
|
||||||
|
color: LightBlue
|
||||||
|
missing:
|
||||||
|
color: Red
|
||||||
|
primary:
|
||||||
|
color: Cyan
|
||||||
|
secondary:
|
||||||
|
color: Yellow
|
||||||
|
success:
|
||||||
|
color: Green
|
||||||
|
system_function:
|
||||||
|
color: Yellow
|
||||||
|
unmonitored:
|
||||||
|
color: Gray
|
||||||
|
unmonitored_missing:
|
||||||
|
color: Yellow
|
||||||
|
unreleased:
|
||||||
|
color: LightCyan
|
||||||
|
warning:
|
||||||
|
color: Magenta
|
||||||
|
"###;
|
||||||
|
|
||||||
|
let serialized_yaml = serde_yaml::to_string(&theme_definition_wrapper).unwrap();
|
||||||
|
|
||||||
|
assert_str_eq!(serialized_yaml, expected_yaml);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use tokio_util::sync::CancellationToken;
|
|||||||
use crate::app::{log_and_print_error, App, AppConfig};
|
use crate::app::{log_and_print_error, App, AppConfig};
|
||||||
use crate::cli::{self, Command};
|
use crate::cli::{self, Command};
|
||||||
use crate::network::Network;
|
use crate::network::Network;
|
||||||
use crate::ui::theme::ThemeDefinition;
|
use crate::ui::theme::ThemeDefinitionsWrapper;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "utils_tests.rs"]
|
#[path = "utils_tests.rs"]
|
||||||
@@ -154,7 +154,7 @@ pub(super) fn load_config(path: &str) -> Result<AppConfig> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn load_theme_config(path: &str) -> Result<Vec<ThemeDefinition>> {
|
pub(super) fn load_theme_config(path: &str) -> Result<ThemeDefinitionsWrapper> {
|
||||||
match File::open(path).map_err(|e| anyhow!(e)) {
|
match File::open(path).map_err(|e| anyhow!(e)) {
|
||||||
Ok(file) => {
|
Ok(file) => {
|
||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
|
|||||||
@@ -2,34 +2,34 @@ use validate_theme_derive::ValidateTheme;
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_theme_derive() {
|
fn test_validate_theme_derive() {
|
||||||
let theme = Theme {
|
let theme = Theme {
|
||||||
name: "test".to_string(),
|
name: "test".to_string(),
|
||||||
good: Some(Style {
|
good: Some(Style {
|
||||||
color: "Green".to_owned(),
|
color: "Green".to_owned(),
|
||||||
}),
|
}),
|
||||||
bad: Some(Style {
|
bad: Some(Style {
|
||||||
color: "Red".to_owned(),
|
color: "Red".to_owned(),
|
||||||
}),
|
}),
|
||||||
ugly: Some(Style {
|
ugly: Some(Style {
|
||||||
color: "Magenta".to_owned(),
|
color: "Magenta".to_owned(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
theme.validate();
|
theme.validate();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
struct Style {
|
struct Style {
|
||||||
color: String,
|
color: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(ValidateTheme)]
|
#[derive(ValidateTheme)]
|
||||||
struct Theme {
|
struct Theme {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[validate]
|
#[validate]
|
||||||
pub good: Option<Style>,
|
pub good: Option<Style>,
|
||||||
#[validate]
|
#[validate]
|
||||||
pub bad: Option<Style>,
|
pub bad: Option<Style>,
|
||||||
pub ugly: Option<Style>,
|
pub ugly: Option<Style>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Managarr Themes
|
||||||
|
The Managarr TUI can be customized to look how you like with various themes.
|
||||||
|
|
||||||
|
There are a few themes included by default with Managarr and are added to your `themes.yml`
|
||||||
|
on first startup. You can simply add more custom themes as you wish to this file.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Built In Themes](#built-in-themes)
|
||||||
|
- [Creating a Custom Theme](#creating-a-custom-theme)
|
||||||
|
|
||||||
|
## Built-In Themes
|
||||||
|
Managarr ships with a handful of built-in themes that you can either use or base your own
|
||||||
|
custom theme off of. The following themes are included by default:
|
||||||
|
|
||||||
|
### [Default](./default/README.md)
|
||||||
|

|
||||||
|
|
||||||
|
### [Dracula](./dracula/README.md)
|
||||||
|

|
||||||
|
|
||||||
|
### [Watermelon Dark](./watermelon-dark/README.md)
|
||||||
|

|
||||||
|
|
||||||
|
## Creating a Custom Theme
|
||||||
|
To create a custom theme, you will need to add a new entry to the `themes.yml` file. If you decide not to add it to the
|
||||||
|
`themes.yml` file, you can also specify a different file to load themes from using the `--themes-file` argument.
|
||||||
|
|
||||||
|
Themes are customizable using hex color codes for the various elements of the TUI. The following
|
||||||
|
is an example that shows every available customization option for a custom theme:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: my-theme
|
||||||
|
theme:
|
||||||
|
background:
|
||||||
|
enabled: true # Disable for transparent backgrounds
|
||||||
|
color: "#233237"
|
||||||
|
awaiting_import:
|
||||||
|
color: "#FFAA42"
|
||||||
|
indeterminate:
|
||||||
|
color: "#FFAA42"
|
||||||
|
default:
|
||||||
|
color: "#FFFFFF"
|
||||||
|
downloaded:
|
||||||
|
color: "#00FF00"
|
||||||
|
downloading:
|
||||||
|
color: "#762671"
|
||||||
|
failure:
|
||||||
|
color: "#DE382B"
|
||||||
|
help:
|
||||||
|
color: "#00FFFF"
|
||||||
|
missing:
|
||||||
|
color: "#DE382B"
|
||||||
|
primary:
|
||||||
|
color: "#2CB5E9"
|
||||||
|
secondary:
|
||||||
|
color: "#FFC706"
|
||||||
|
success:
|
||||||
|
color: "#39B54A"
|
||||||
|
system_function:
|
||||||
|
color: "#FFC706"
|
||||||
|
unmonitored:
|
||||||
|
color: "#808080"
|
||||||
|
unmonitored_missing:
|
||||||
|
color: "#FFC706"
|
||||||
|
unreleased:
|
||||||
|
color: "#00FFFF"
|
||||||
|
warning:
|
||||||
|
color: "#FF00FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
In order to activate your custom theme, you can either update your `config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
theme: my-theme
|
||||||
|
radarr:
|
||||||
|
...
|
||||||
|
sonarr:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Or you can test out your theme via the `--theme` flag on the CLI:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
managarr --theme my-theme
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're developing your own theme and don't want to add it to the main `themes.yml` file, you can
|
||||||
|
also use the `--themes-file` argument to specify a different file to load themes from:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
managarr --themes-file /path/to/my/testing-themes.yml
|
||||||
|
```
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Managarr Default Theme
|
||||||
|
The [themes.yml](./themes.yml) file in this directory corresponds to the theme configuration for the
|
||||||
|
default Managarr theme.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 217 KiB |
@@ -0,0 +1,37 @@
|
|||||||
|
- name: default
|
||||||
|
theme:
|
||||||
|
background:
|
||||||
|
enabled: true
|
||||||
|
color: "#233237"
|
||||||
|
awaiting_import:
|
||||||
|
color: "#FFAA42"
|
||||||
|
indeterminate:
|
||||||
|
color: "#FFAA42"
|
||||||
|
default:
|
||||||
|
color: "#FFFFFF"
|
||||||
|
downloaded:
|
||||||
|
color: "#00FF00"
|
||||||
|
downloading:
|
||||||
|
color: "#762671"
|
||||||
|
failure:
|
||||||
|
color: "#DE382B"
|
||||||
|
help:
|
||||||
|
color: "#00FFFF"
|
||||||
|
missing:
|
||||||
|
color: "#DE382B"
|
||||||
|
primary:
|
||||||
|
color: "#2CB5E9"
|
||||||
|
secondary:
|
||||||
|
color: "#FFC706"
|
||||||
|
success:
|
||||||
|
color: "#39B54A"
|
||||||
|
system_function:
|
||||||
|
color: "#FFC706"
|
||||||
|
unmonitored:
|
||||||
|
color: "#808080"
|
||||||
|
unmonitored_missing:
|
||||||
|
color: "#FFC706"
|
||||||
|
unreleased:
|
||||||
|
color: "#00FFFF"
|
||||||
|
warning:
|
||||||
|
color: "#FF00FF"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Managarr Dracula Theme
|
||||||
|
The [themes.yml](./themes.yml) file in this directory corresponds to the theme configuration for the
|
||||||
|
Dracula Managarr theme.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 257 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
- name: dracula
|
||||||
|
theme:
|
||||||
|
background:
|
||||||
|
enabled: false
|
||||||
|
color: "#282a36"
|
||||||
|
default:
|
||||||
|
color: "#f8f8f2"
|
||||||
|
downloaded:
|
||||||
|
color: "#50fa7b"
|
||||||
|
downloading:
|
||||||
|
color: "#8be9fd"
|
||||||
|
failure:
|
||||||
|
color: "#ff5555"
|
||||||
|
missing:
|
||||||
|
color: "#ffb86c"
|
||||||
|
unmonitored-missing:
|
||||||
|
color: "#6272a4"
|
||||||
|
help:
|
||||||
|
color: "#6272a4"
|
||||||
|
primary:
|
||||||
|
color: "#ff79c6"
|
||||||
|
secondary:
|
||||||
|
color: "#ff79c6"
|
||||||
|
success:
|
||||||
|
color: "#50fa7b"
|
||||||
|
warning:
|
||||||
|
color: "#f1fa8c"
|
||||||
|
unreleased:
|
||||||
|
color: "#f8f8f2"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Managarr Watermelon Dark Theme
|
||||||
|
The [themes.yml](./themes.yml) file in this directory corresponds to the theme configuration for the
|
||||||
|
Watermelon Dark Managarr theme.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 255 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
- name: watermelon-dark
|
||||||
|
theme:
|
||||||
|
background:
|
||||||
|
enabled: false
|
||||||
|
default:
|
||||||
|
color: "#00FF00"
|
||||||
|
downloaded:
|
||||||
|
color: "#80ffbf"
|
||||||
|
failure:
|
||||||
|
color: "#ff8080"
|
||||||
|
missing:
|
||||||
|
color: "#ff8080"
|
||||||
|
primary:
|
||||||
|
color: "#ff19d9"
|
||||||
|
secondary:
|
||||||
|
color: "#8c19ff"
|
||||||