Merge pull request #37 from Dark-Alex-17/custom-themes

Support Themes
This commit is contained in:
Alex Clarke
2025-03-17 14:23:18 -06:00
committed by GitHub
64 changed files with 1657 additions and 133 deletions
Generated
+10
View File
@@ -1391,6 +1391,7 @@ dependencies = [
"tokio", "tokio",
"tokio-util", "tokio-util",
"urlencoding", "urlencoding",
"validate_theme_derive",
"veil", "veil",
] ]
@@ -2780,6 +2781,15 @@ dependencies = [
"getrandom 0.3.1", "getrandom 0.3.1",
] ]
[[package]]
name = "validate_theme_derive"
version = "0.1.0"
dependencies = [
"log",
"quote",
"syn 2.0.99",
]
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
+2 -1
View File
@@ -14,7 +14,7 @@ rust-version = "1.85.0"
exclude = [".github", "CONTRIBUTING.md", "*.log", "tags"] exclude = [".github", "CONTRIBUTING.md", "*.log", "tags"]
[workspace] [workspace]
members = ["proc_macros/enum_display_style_derive"] members = ["proc_macros/enum_display_style_derive", "proc_macros/validate_theme_derive"]
[dependencies] [dependencies]
anyhow = "1.0.68" anyhow = "1.0.68"
@@ -63,6 +63,7 @@ deunicode = "1.6.0"
paste = "1.0.15" paste = "1.0.15"
openssl = { version = "0.10.70", features = ["vendored"] } openssl = { version = "0.10.70", features = ["vendored"] }
veil = "0.2.0" veil = "0.2.0"
validate_theme_derive = { path = "proc_macros/validate_theme_derive" }
enum_display_style_derive = { path = "proc_macros/enum_display_style_derive" } enum_display_style_derive = { path = "proc_macros/enum_display_style_derive" }
[dev-dependencies] [dev-dependencies]
+15 -1
View File
@@ -206,6 +206,16 @@ Key:
- [ ] Support for Tautulli - [ ] Support for Tautulli
### Themes
Managarr ships with a few themes out of the box. Here's a few examples:
![default](themes/default/manual_episode_search.png)
![dracula](themes/dracula/manual_episode_search.png)
![watermelon-dark](themes/watermelon-dark/manual_episode_search.png)
You can also create your own custom themes as well. To learn more about what themes are built-in to Managarr and how
to create your own custom themes, check out the [Themes README](themes/README.md).
### 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.
@@ -218,7 +228,7 @@ To see all available commands, simply run `managarr --help`:
```shell ```shell
$ managarr --help $ managarr --help
managarr 0.5.0 managarr 0.5.1
Alex Clarke <alex.j.tusa@gmail.com> Alex Clarke <alex.j.tusa@gmail.com>
A TUI and CLI to manage your Servarrs A TUI and CLI to manage your Servarrs
@@ -235,6 +245,8 @@ Commands:
Options: Options:
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=] --disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config-file <CONFIG_FILE> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=] --config-file <CONFIG_FILE> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
--themes-file <THEMES_FILE> The Managarr themes file to use [env: MANAGARR_THEMES_FILE=]
--theme <THEME> The name of the Managarr theme to use [env: MANAGARR_THEME=]
--servarr-name <SERVARR_NAME> For multi-instance configurations, you need to specify the name of the instance configuration that you want to use. --servarr-name <SERVARR_NAME> For multi-instance configurations, you need to specify the name of the instance configuration that you want to use.
This is useful when you have multiple instances of the same Servarr defined in your config file. This is useful when you have multiple instances of the same Servarr defined in your config file.
By default, if left empty, the first configured Servarr instance listed in the config file will be used. By default, if left empty, the first configured Servarr instance listed in the config file will be used.
@@ -315,6 +327,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 +370,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
@@ -5,7 +5,7 @@ use darling::FromVariant;
use quote::quote; use quote::quote;
use syn::{Data, DeriveInput, parse_macro_input}; use syn::{Data, DeriveInput, parse_macro_input};
/// Derive macro for the EnumDisplayStyle trait. /// Derive macro for generating a `to_display_str` method for an enum.
/// ///
/// # Example /// # Example
/// ///
@@ -22,7 +22,6 @@ use syn::{Data, DeriveInput, parse_macro_input};
/// ///
/// assert_eq!(Weekend::Saturday.to_display_str(), "Saturday"); /// assert_eq!(Weekend::Saturday.to_display_str(), "Saturday");
/// assert_eq!(Weekend::Sunday.to_display_str(), "Sunday"); /// assert_eq!(Weekend::Sunday.to_display_str(), "Sunday");
///
/// ``` /// ```
/// ///
/// Using custom values for the display style: /// Using custom values for the display style:
@@ -0,0 +1,14 @@
[package]
name = "validate_theme_derive"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.39"
syn = "2.0.99"
[dev-dependencies]
log = "0.4.17"
@@ -0,0 +1,106 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields, parse_macro_input};
/// 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`.
/// Otherwise, an error message it output to both the log file and stdout and the program exits.
///
/// # Example
///
/// Valid themes pass through the program transitively without any messages being output.
///
/// ```
/// use validate_theme_derive::ValidateTheme;
///
/// #[derive(ValidateTheme, Default)]
/// struct Theme {
/// pub name: String,
/// #[validate]
/// pub good: Option<Style>,
/// #[validate]
/// pub bad: Option<Style>,
/// pub ugly: Option<Style>,
/// }
///
/// struct Style {
/// color: String,
/// }
///
/// let theme = Theme {
/// good: Some(Style { color: "Green".to_owned() }),
/// bad: Some(Style { color: "Red".to_owned() }),
/// ..Theme::default()
/// };
///
/// // Since only `good` and `bad` have the `validate` attribute, the `validate` method will only check those fields.
/// theme.validate();
/// // 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.
///
/// ```should_panic
/// use validate_theme_derive::ValidateTheme;
///
/// #[derive(ValidateTheme, Default)]
/// struct Theme {
/// pub name: String,
/// #[validate]
/// pub good: Option<Style>,
/// #[validate]
/// pub bad: Option<Style>,
/// pub ugly: Option<Style>,
/// }
///
/// struct Style {
/// color: String,
/// }
///
/// let theme = Theme {
/// bad: Some(Style { color: "Red".to_owned() }),
/// ..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.
/// theme.validate();
/// ```
#[proc_macro_derive(ValidateTheme, attributes(validate))]
pub fn derive_validate_theme(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let struct_name = &input.ident;
let mut validation_checks = Vec::new();
if let Data::Struct(data_struct) = &input.data {
if let Fields::Named(fields) = &data_struct.fields {
for field in &fields.named {
let field_name = &field.ident;
let has_validate_attr = field
.attrs
.iter()
.any(|attr| attr.path().is_ident("validate"));
if has_validate_attr {
validation_checks.push(quote! {
if self.#field_name.is_none() {
log::error!("{} is missing a color value.", stringify!(#field_name));
eprintln!("{} is missing a color value.", stringify!(#field_name));
std::process::exit(1);
}
})
}
}
}
}
quote! {
impl #struct_name {
pub fn validate(&self) {
#(#validation_checks)*
}
}
}
.into()
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 220 KiB

+1
View File
@@ -31,6 +31,7 @@ mod tests {
}; };
let sonarr_config_2 = ServarrConfig::default(); let sonarr_config_2 = ServarrConfig::default();
let config = AppConfig { let config = AppConfig {
theme: None,
radarr: Some(vec![radarr_config_1.clone(), radarr_config_2.clone()]), radarr: Some(vec![radarr_config_1.clone(), radarr_config_2.clone()]),
sonarr: Some(vec![sonarr_config_1.clone(), sonarr_config_2.clone()]), sonarr: Some(vec![sonarr_config_1.clone(), sonarr_config_2.clone()]),
}; };
+1
View File
@@ -270,6 +270,7 @@ pub struct Data<'a> {
#[derive(Debug, Deserialize, Serialize, Default, Clone)] #[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct AppConfig { pub struct AppConfig {
pub theme: Option<String>,
pub radarr: Option<Vec<ServarrConfig>>, pub radarr: Option<Vec<ServarrConfig>>,
pub sonarr: Option<Vec<ServarrConfig>>, pub sonarr: Option<Vec<ServarrConfig>>,
} }
+63 -10
View File
@@ -1,10 +1,4 @@
use anyhow::Result; 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::{crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser};
use clap_complete::generate; use clap_complete::generate;
use crossterm::execute; use crossterm::execute;
@@ -16,6 +10,11 @@ use network::NetworkTrait;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
use reqwest::Client; 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::select;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use tokio::sync::{mpsc, Mutex}; 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, 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::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::ui; use crate::ui::theme::{Theme, ThemeDefinitionsWrapper};
use crate::ui::{ui, THEME};
use crate::utils::load_theme_config;
mod app; mod app;
mod cli; mod cli;
@@ -74,6 +75,22 @@ struct Cli {
help = "The Managarr configuration file to use" help = "The Managarr configuration file to use"
)] )]
config_file: Option<PathBuf>, config_file: Option<PathBuf>,
#[arg(
long,
global = true,
value_parser,
env = "MANAGARR_THEMES_FILE",
help = "The Managarr themes file to use"
)]
themes_file: Option<PathBuf>,
#[arg(
long,
global = true,
value_parser,
env = "MANAGARR_THEME",
help = "The name of the Managarr theme to use"
)]
theme: Option<String>,
#[arg( #[arg(
long, long,
global = true, global = true,
@@ -98,10 +115,12 @@ async fn main() -> Result<()> {
} else { } else {
confy::load("managarr", "config")? confy::load("managarr", "config")?
}; };
let theme_name = config.theme.clone();
let spinner_disabled = args.disable_spinner; let spinner_disabled = args.disable_spinner;
debug!("Managarr loaded using config: {config:?}"); debug!("Managarr loaded using config: {config:?}");
config.validate(); config.validate();
config.post_process_initialization(); config.post_process_initialization();
let reqwest_client = build_network_client(&config); let reqwest_client = build_network_client(&config);
let (sync_network_tx, sync_network_rx) = mpsc::channel(500); let (sync_network_tx, sync_network_rx) = mpsc::channel(500);
let cancellation_token = CancellationToken::new(); let cancellation_token = CancellationToken::new();
@@ -140,7 +159,12 @@ async fn main() -> Result<()> {
std::thread::spawn(move || { std::thread::spawn(move || {
start_networking(sync_network_rx, &app_nw, cancellation_token, reqwest_client) start_networking(sync_network_rx, &app_nw, cancellation_token, reqwest_client)
}); });
start_ui(&app).await?; start_ui(
&app,
&args.themes_file,
args.theme.unwrap_or(theme_name.unwrap_or_default()),
)
.await?;
} }
} }
@@ -174,7 +198,36 @@ async fn start_networking(
} }
} }
async fn start_ui(app: &Arc<Mutex<App<'_>>>) -> Result<()> { async fn start_ui(
app: &Arc<Mutex<App<'_>>>,
themes_file_arg: &Option<PathBuf>,
theme_name: String,
) -> Result<()> {
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"))?
} else {
confy::load("managarr", "themes").unwrap_or_else(|_| ThemeDefinitionsWrapper::default())
};
let theme = if !theme_name.is_empty() {
let theme_definition = theme_definitions_wrapper
.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.validate();
THEME.set(theme);
let mut stdout = io::stdout(); let mut stdout = io::stdout();
enable_raw_mode()?; enable_raw_mode()?;
+148
View File
@@ -0,0 +1,148 @@
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(in crate::ui) fn watermelon_dark_theme() -> Theme {
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()
}
}
pub(in crate::ui) fn dracula_theme() -> Theme {
Theme {
background: Some(Background {
enabled: Some(true),
color: Some(Color::from_str("#232326").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()
}
}
pub(in crate::ui) fn eldritch_theme() -> Theme {
Theme {
background: Some(Background {
enabled: Some(true),
color: Some(Color::from_str("#212337").unwrap()),
}),
default: Some(Style {
color: Some(Color::from_str("#ebfafa").unwrap()),
}),
downloaded: Some(Style {
color: Some(Color::from_str("#37f499").unwrap()),
}),
downloading: Some(Style {
color: Some(Color::from_str("#f7c67f").unwrap()),
}),
failure: Some(Style {
color: Some(Color::from_str("#f16c75").unwrap()),
}),
missing: Some(Style {
color: Some(Color::from_str("#f7c67f").unwrap()),
}),
unmonitored_missing: Some(Style {
color: Some(Color::from_str("#7081d0").unwrap()),
}),
help: Some(Style {
color: Some(Color::from_str("#7081d0").unwrap()),
}),
primary: Some(Style {
color: Some(Color::from_str("#f265b5").unwrap()),
}),
secondary: Some(Style {
color: Some(Color::from_str("#04d1f9").unwrap()),
}),
success: Some(Style {
color: Some(Color::from_str("#37f499").unwrap()),
}),
warning: Some(Style {
color: Some(Color::from_str("#f1fc79").unwrap()),
}),
unreleased: Some(Style {
color: Some(Color::from_str("#ebfafa").unwrap()),
}),
..Theme::default()
}
}
pub fn get_builtin_themes() -> Vec<ThemeDefinition> {
vec![
ThemeDefinition {
name: "default".to_owned(),
theme: Theme::default(),
},
ThemeDefinition {
name: "watermelon-dark".to_owned(),
theme: watermelon_dark_theme(),
},
ThemeDefinition {
name: "dracula".to_owned(),
theme: dracula_theme(),
},
ThemeDefinition {
name: "eldritch".to_owned(),
theme: eldritch_theme(),
},
]
}
+143
View File
@@ -0,0 +1,143 @@
#[cfg(test)]
mod test {
use crate::ui::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(true),
color: Some(Color::from_str("#232326").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 eldritch = Theme {
background: Some(Background {
enabled: Some(true),
color: Some(Color::from_str("#212337").unwrap()),
}),
default: Some(Style {
color: Some(Color::from_str("#ebfafa").unwrap()),
}),
downloaded: Some(Style {
color: Some(Color::from_str("#37f499").unwrap()),
}),
downloading: Some(Style {
color: Some(Color::from_str("#f7c67f").unwrap()),
}),
failure: Some(Style {
color: Some(Color::from_str("#f16c75").unwrap()),
}),
missing: Some(Style {
color: Some(Color::from_str("#f7c67f").unwrap()),
}),
unmonitored_missing: Some(Style {
color: Some(Color::from_str("#7081d0").unwrap()),
}),
help: Some(Style {
color: Some(Color::from_str("#7081d0").unwrap()),
}),
primary: Some(Style {
color: Some(Color::from_str("#f265b5").unwrap()),
}),
secondary: Some(Style {
color: Some(Color::from_str("#04d1f9").unwrap()),
}),
success: Some(Style {
color: Some(Color::from_str("#37f499").unwrap()),
}),
warning: Some(Style {
color: Some(Color::from_str("#f1fc79").unwrap()),
}),
unreleased: Some(Style {
color: Some(Color::from_str("#ebfafa").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,
},
ThemeDefinition {
name: "eldritch".to_owned(),
theme: eldritch,
},
];
assert_eq!(expected_themes, get_builtin_themes());
}
}
+12 -2
View File
@@ -1,3 +1,4 @@
use std::cell::Cell;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::layout::{Constraint, Flex, Layout, Rect};
@@ -15,19 +16,26 @@ use crate::app::App;
use crate::models::{HorizontallyScrollableText, Route, TabState}; use crate::models::{HorizontallyScrollableText, Route, TabState};
use crate::ui::radarr_ui::RadarrUi; use crate::ui::radarr_ui::RadarrUi;
use crate::ui::styles::ManagarrStyle; use crate::ui::styles::ManagarrStyle;
use crate::ui::theme::Theme;
use crate::ui::utils::{ use crate::ui::utils::{
background_block, borderless_block, centered_rect, logo_block, title_block, title_block_centered, background_block, borderless_block, centered_rect, logo_block, title_block, title_block_centered,
unstyled_title_block,
}; };
use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::input_box::InputBox;
use crate::ui::widgets::popup::Size; use crate::ui::widgets::popup::Size;
mod builtin_themes;
mod radarr_ui; mod radarr_ui;
mod sonarr_ui; mod sonarr_ui;
mod styles; mod styles;
pub mod theme;
mod utils; mod utils;
mod widgets; mod widgets;
static HIGHLIGHT_SYMBOL: &str = "=> "; static HIGHLIGHT_SYMBOL: &str = "=> ";
thread_local! {
pub static THEME: Cell<Theme> = Cell::new(Theme::default());
}
pub trait DrawUi { pub trait DrawUi {
fn accepts(route: Route) -> bool; fn accepts(route: Route) -> bool;
@@ -100,7 +108,9 @@ fn draw_header_row(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
} }
fn draw_error(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { fn draw_error(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let block = title_block("Error | <esc> to close").failure().bold(); let block = unstyled_title_block("Error | <esc> to close")
.failure()
.bold();
app.error.scroll_left_or_reset( app.error.scroll_left_or_reset(
area.width as usize, area.width as usize,
@@ -130,7 +140,7 @@ pub fn draw_popup(
fn draw_tabs(f: &mut Frame<'_>, area: Rect, title: &str, tab_state: &TabState) -> Rect { fn draw_tabs(f: &mut Frame<'_>, area: Rect, title: &str, tab_state: &TabState) -> Rect {
if title.is_empty() { if title.is_empty() {
f.render_widget(layout_block(), area); f.render_widget(layout_block().default(), area);
} else { } else {
f.render_widget(title_block(title), area); f.render_widget(title_block(title), area);
} }
@@ -33,7 +33,7 @@ impl DrawUi for CollectionDetailsUi {
if let Route::Radarr(active_radarr_block, context_option) = route { if let Route::Radarr(active_radarr_block, context_option) = route {
if let Some(context) = context_option { if let Some(context) = context_option {
return COLLECTION_DETAILS_BLOCKS.contains(&active_radarr_block) return COLLECTION_DETAILS_BLOCKS.contains(&active_radarr_block)
&& context == ActiveRadarrBlock::CollectionDetails; || context == ActiveRadarrBlock::CollectionDetails;
} }
return COLLECTION_DETAILS_BLOCKS.contains(&active_radarr_block); return COLLECTION_DETAILS_BLOCKS.contains(&active_radarr_block);
@@ -25,5 +25,12 @@ mod tests {
) )
.into() .into()
)); ));
assert!(CollectionDetailsUi::accepts(
(
ActiveRadarrBlock::AddMoviePrompt,
Some(ActiveRadarrBlock::CollectionDetails)
)
.into()
));
} }
} }
@@ -88,6 +88,7 @@ fn draw_edit_collection_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>
.clone() .clone()
.unwrap_or_default(); .unwrap_or_default();
let title = format!("Edit - {collection_title}"); let title = format!("Edit - {collection_title}");
f.render_widget(title_block_centered(&title), area);
let yes_no_value = app.data.radarr_data.prompt_confirm; let yes_no_value = app.data.radarr_data.prompt_confirm;
let selected_block = app.data.radarr_data.selected_block.get_active_block(); let selected_block = app.data.radarr_data.selected_block.get_active_block();
let highlight_yes_no = selected_block == ActiveRadarrBlock::EditCollectionConfirmPrompt; let highlight_yes_no = selected_block == ActiveRadarrBlock::EditCollectionConfirmPrompt;
@@ -155,7 +156,6 @@ fn draw_edit_collection_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>
.title("Cancel") .title("Cancel")
.selected(!yes_no_value && highlight_yes_no); .selected(!yes_no_value && highlight_yes_no);
f.render_widget(title_block_centered(&title), area);
f.render_widget(prompt_paragraph, paragraph_area); f.render_widget(prompt_paragraph, paragraph_area);
f.render_widget(monitored_checkbox, monitored_area); f.render_widget(monitored_checkbox, monitored_area);
f.render_widget(min_availability_drop_down_button, min_availability_area); f.render_widget(min_availability_drop_down_button, min_availability_area);
+1 -1
View File
@@ -49,6 +49,7 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let help_paragraph = Paragraph::new(help_text).centered(); let help_paragraph = Paragraph::new(help_text).centered();
if edit_indexer_modal_option.is_some() { if edit_indexer_modal_option.is_some() {
f.render_widget(block, area);
let edit_indexer_modal = edit_indexer_modal_option.as_ref().unwrap(); let edit_indexer_modal = edit_indexer_modal_option.as_ref().unwrap();
let [_, settings_area, _, buttons_area, help_area] = Layout::vertical([ let [_, settings_area, _, buttons_area, help_area] = Layout::vertical([
@@ -163,7 +164,6 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.title("Cancel") .title("Cancel")
.selected(!yes_no_value && highlight_yes_no); .selected(!yes_no_value && highlight_yes_no);
f.render_widget(block, area);
f.render_widget(rss_checkbox, rss_area); f.render_widget(rss_checkbox, rss_area);
f.render_widget(auto_search_checkbox, auto_search_area); f.render_widget(auto_search_checkbox, auto_search_area);
f.render_widget(interactive_search_checkbox, interactive_search_area); f.render_widget(interactive_search_checkbox, interactive_search_area);
@@ -56,6 +56,7 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area:
let help_paragraph = Paragraph::new(help_text).centered(); let help_paragraph = Paragraph::new(help_text).centered();
if indexer_settings_option.is_some() { if indexer_settings_option.is_some() {
f.render_widget(block, area);
let indexer_settings = indexer_settings_option.as_ref().unwrap(); let indexer_settings = indexer_settings_option.as_ref().unwrap();
let [_, settings_area, _, buttons_area, help_area] = Layout::vertical([ let [_, settings_area, _, buttons_area, help_area] = Layout::vertical([
@@ -162,7 +163,6 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area:
.title("Cancel") .title("Cancel")
.selected(!yes_no_value && highlight_yes_no); .selected(!yes_no_value && highlight_yes_no);
f.render_widget(block, area);
f.render_widget(prefer_indexer_flags_checkbox, prefer_flags_area); f.render_widget(prefer_indexer_flags_checkbox, prefer_flags_area);
f.render_widget(allow_hardcoded_subs_checkbox, allow_hardcoded_subs_area); f.render_widget(allow_hardcoded_subs_checkbox, allow_hardcoded_subs_area);
f.render_widget(save_button, save_area); f.render_widget(save_button, save_area);
@@ -4,7 +4,7 @@ use crate::models::servarr_data::modals::IndexerTestResultModalItem;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::Route; use crate::models::Route;
use crate::ui::styles::ManagarrStyle; use crate::ui::styles::ManagarrStyle;
use crate::ui::utils::{borderless_block, get_width_from_percentage, title_block}; use crate::ui::utils::{get_width_from_percentage, title_block};
use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::ui::widgets::popup::Size; use crate::ui::widgets::popup::Size;
use crate::ui::{draw_popup, DrawUi}; use crate::ui::{draw_popup, DrawUi};
@@ -71,7 +71,6 @@ fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, are
app.data.radarr_data.indexer_test_all_results.as_mut(), app.data.radarr_data.indexer_test_all_results.as_mut(),
test_results_row_mapping, test_results_row_mapping,
) )
.block(borderless_block())
.loading(is_loading) .loading(is_loading)
.footer(Some(help_footer)) .footer(Some(help_footer))
.footer_alignment(Alignment::Center) .footer_alignment(Alignment::Center)
+3 -3
View File
@@ -170,7 +170,7 @@ fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.centered(); .centered();
search_box.show_cursor(f, search_box_area); search_box.show_cursor(f, search_box_area);
f.render_widget(layout_block(), results_area); f.render_widget(layout_block().default(), results_area);
f.render_widget(search_box, search_box_area); f.render_widget(search_box, search_box_area);
f.render_widget(help_paragraph, help_area); f.render_widget(help_paragraph, help_area);
} }
@@ -182,7 +182,7 @@ fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let error_message = Message::new("No movies found matching your query!"); let error_message = Message::new("No movies found matching your query!");
let error_message_popup = Popup::new(error_message).size(Size::Message); let error_message_popup = Popup::new(error_message).size(Size::Message);
f.render_widget(layout_block(), results_area); f.render_widget(layout_block().default(), results_area);
f.render_widget(error_message_popup, f.area()); f.render_widget(error_message_popup, f.area());
f.render_widget(help_paragraph, help_area); f.render_widget(help_paragraph, help_area);
} }
@@ -204,7 +204,7 @@ fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
search_results_row_mapping, search_results_row_mapping,
) )
.loading(is_loading) .loading(is_loading)
.block(layout_block()) .block(layout_block().default())
.headers([ .headers([
"", "",
"Title", "Title",
+1 -1
View File
@@ -80,6 +80,7 @@ fn draw_edit_movie_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, are
.overview .overview
.clone(); .clone();
let title = format!("Edit - {movie_title}"); let title = format!("Edit - {movie_title}");
f.render_widget(title_block_centered(&title), area);
let yes_no_value = app.data.radarr_data.prompt_confirm; let yes_no_value = app.data.radarr_data.prompt_confirm;
let selected_block = app.data.radarr_data.selected_block.get_active_block(); let selected_block = app.data.radarr_data.selected_block.get_active_block();
let highlight_yes_no = selected_block == ActiveRadarrBlock::EditMovieConfirmPrompt; let highlight_yes_no = selected_block == ActiveRadarrBlock::EditMovieConfirmPrompt;
@@ -157,7 +158,6 @@ fn draw_edit_movie_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, are
.title("Cancel") .title("Cancel")
.selected(!yes_no_value && highlight_yes_no); .selected(!yes_no_value && highlight_yes_no);
f.render_widget(title_block_centered(&title), area);
f.render_widget(prompt_paragraph, paragraph_area); f.render_widget(prompt_paragraph, paragraph_area);
f.render_widget(monitored_checkbox, monitored_area); f.render_widget(monitored_checkbox, monitored_area);
f.render_widget(min_availability_drop_down_button, min_availability_area); f.render_widget(min_availability_drop_down_button, min_availability_area);
+7
View File
@@ -44,6 +44,12 @@ impl DrawUi for LibraryUi {
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let route = app.get_current_route(); let route = app.get_current_route();
if let Route::Radarr(_, context_option) = route {
if context_option.is_some() && AddMovieUi::accepts(route) {
AddMovieUi::draw(f, app, area);
return;
}
draw_library(f, app, area); draw_library(f, app, area);
match route { match route {
@@ -66,6 +72,7 @@ impl DrawUi for LibraryUi {
} }
} }
} }
}
fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { if let Route::Radarr(active_radarr_block, _) = app.get_current_route() {
+3 -3
View File
@@ -256,7 +256,7 @@ fn draw_movie_history(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
Cell::from(quality.quality.name.to_owned()), Cell::from(quality.quality.name.to_owned()),
Cell::from(date.to_string()), Cell::from(date.to_string()),
]) ])
.success() .primary()
}; };
let help_footer = app let help_footer = app
.data .data
@@ -297,7 +297,7 @@ fn draw_movie_cast(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
Cell::from(person_name.to_owned()), Cell::from(person_name.to_owned()),
Cell::from(character.clone().unwrap_or_default()), Cell::from(character.clone().unwrap_or_default()),
]) ])
.success() .primary()
}; };
let content = Some(&mut movie_details_modal.movie_cast); let content = Some(&mut movie_details_modal.movie_cast);
let help_footer = app let help_footer = app
@@ -340,7 +340,7 @@ fn draw_movie_crew(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
Cell::from(job.clone().unwrap_or_default()), Cell::from(job.clone().unwrap_or_default()),
Cell::from(department.clone().unwrap_or_default()), Cell::from(department.clone().unwrap_or_default()),
]) ])
.success() .primary()
}; };
let content = Some(&mut movie_details_modal.movie_crew); let content = Some(&mut movie_details_modal.movie_crew);
let help_footer = app let help_footer = app
@@ -105,7 +105,6 @@ fn draw_tasks_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.primary() .primary()
}; };
let tasks_table = ManagarrTable::new(Some(&mut app.data.radarr_data.tasks), tasks_row_mapping) let tasks_table = ManagarrTable::new(Some(&mut app.data.radarr_data.tasks), tasks_row_mapping)
.block(borderless_block())
.loading(app.is_loading) .loading(app.is_loading)
.margin(1) .margin(1)
.footer(help_footer) .footer(help_footer)
+1 -1
View File
@@ -49,6 +49,7 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let help_paragraph = Paragraph::new(help_text).centered(); let help_paragraph = Paragraph::new(help_text).centered();
if edit_indexer_modal_option.is_some() { if edit_indexer_modal_option.is_some() {
f.render_widget(block, area);
let edit_indexer_modal = edit_indexer_modal_option.as_ref().unwrap(); let edit_indexer_modal = edit_indexer_modal_option.as_ref().unwrap();
let [_, settings_area, _, buttons_area, help_area] = Layout::vertical([ let [_, settings_area, _, buttons_area, help_area] = Layout::vertical([
@@ -163,7 +164,6 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.title("Cancel") .title("Cancel")
.selected(!yes_no_value && highlight_yes_no); .selected(!yes_no_value && highlight_yes_no);
f.render_widget(block, area);
f.render_widget(rss_checkbox, rss_area); f.render_widget(rss_checkbox, rss_area);
f.render_widget(auto_search_checkbox, auto_search_area); f.render_widget(auto_search_checkbox, auto_search_area);
f.render_widget(interactive_search_checkbox, interactive_search_area); f.render_widget(interactive_search_checkbox, interactive_search_area);
@@ -48,6 +48,7 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area:
let help_paragraph = Paragraph::new(help_text).centered(); let help_paragraph = Paragraph::new(help_text).centered();
if indexer_settings_option.is_some() { if indexer_settings_option.is_some() {
f.render_widget(block, area);
let indexer_settings = indexer_settings_option.as_ref().unwrap(); let indexer_settings = indexer_settings_option.as_ref().unwrap();
let [_, min_age_area, retention_area, max_size_area, rss_sync_area, _, buttons_area, help_area] = let [_, min_age_area, retention_area, max_size_area, rss_sync_area, _, buttons_area, help_area] =
@@ -109,7 +110,6 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area:
.title("Cancel") .title("Cancel")
.selected(!yes_no_value && highlight_yes_no); .selected(!yes_no_value && highlight_yes_no);
f.render_widget(block, area);
f.render_widget(save_button, save_area); f.render_widget(save_button, save_area);
f.render_widget(cancel_button, cancel_area); f.render_widget(cancel_button, cancel_area);
f.render_widget(help_paragraph, help_area); f.render_widget(help_paragraph, help_area);
@@ -4,7 +4,7 @@ use crate::models::servarr_data::modals::IndexerTestResultModalItem;
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use crate::models::Route; use crate::models::Route;
use crate::ui::styles::ManagarrStyle; use crate::ui::styles::ManagarrStyle;
use crate::ui::utils::{borderless_block, get_width_from_percentage, title_block}; use crate::ui::utils::{get_width_from_percentage, title_block};
use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::ui::widgets::popup::Size; use crate::ui::widgets::popup::Size;
use crate::ui::{draw_popup, DrawUi}; use crate::ui::{draw_popup, DrawUi};
@@ -69,7 +69,6 @@ fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, are
app.data.sonarr_data.indexer_test_all_results.as_mut(), app.data.sonarr_data.indexer_test_all_results.as_mut(),
test_results_row_mapping, test_results_row_mapping,
) )
.block(borderless_block())
.loading(is_loading) .loading(is_loading)
.footer(Some(help_footer)) .footer(Some(help_footer))
.footer_alignment(Alignment::Center) .footer_alignment(Alignment::Center)
+3 -3
View File
@@ -156,7 +156,7 @@ fn draw_add_series_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.centered(); .centered();
search_box.show_cursor(f, search_box_area); search_box.show_cursor(f, search_box_area);
f.render_widget(layout_block(), results_area); f.render_widget(layout_block().default(), results_area);
f.render_widget(search_box, search_box_area); f.render_widget(search_box, search_box_area);
f.render_widget(help_paragraph, help_area); f.render_widget(help_paragraph, help_area);
} }
@@ -168,7 +168,7 @@ fn draw_add_series_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let error_message = Message::new("No series found matching your query!"); let error_message = Message::new("No series found matching your query!");
let error_message_popup = Popup::new(error_message).size(Size::Message); let error_message_popup = Popup::new(error_message).size(Size::Message);
f.render_widget(layout_block(), results_area); f.render_widget(layout_block().default(), results_area);
f.render_widget(error_message_popup, f.area()); f.render_widget(error_message_popup, f.area());
f.render_widget(help_paragraph, help_area); f.render_widget(help_paragraph, help_area);
} }
@@ -191,7 +191,7 @@ fn draw_add_series_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
search_results_row_mapping, search_results_row_mapping,
) )
.loading(is_loading) .loading(is_loading)
.block(layout_block()) .block(layout_block().default())
.headers([ .headers([
"", "Title", "Year", "Network", "Seasons", "Rating", "Genres", "", "Title", "Year", "Network", "Seasons", "Rating", "Genres",
]) ])
+2 -1
View File
@@ -89,6 +89,8 @@ fn draw_edit_series_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, ar
.clone() .clone()
.unwrap_or_default(); .unwrap_or_default();
let title = format!("Edit - {series_title}"); let title = format!("Edit - {series_title}");
f.render_widget(title_block_centered(&title), area);
let yes_no_value = app.data.sonarr_data.prompt_confirm; let yes_no_value = app.data.sonarr_data.prompt_confirm;
let selected_block = app.data.sonarr_data.selected_block.get_active_block(); let selected_block = app.data.sonarr_data.selected_block.get_active_block();
let highlight_yes_no = selected_block == ActiveSonarrBlock::EditSeriesConfirmPrompt; let highlight_yes_no = selected_block == ActiveSonarrBlock::EditSeriesConfirmPrompt;
@@ -179,7 +181,6 @@ fn draw_edit_series_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, ar
.title("Cancel") .title("Cancel")
.selected(!yes_no_value && highlight_yes_no); .selected(!yes_no_value && highlight_yes_no);
f.render_widget(title_block_centered(&title), area);
f.render_widget(prompt_paragraph, paragraph_area); f.render_widget(prompt_paragraph, paragraph_area);
f.render_widget(monitored_checkbox, monitored_area); f.render_widget(monitored_checkbox, monitored_area);
f.render_widget(season_folder_checkbox, season_folder_area); f.render_widget(season_folder_checkbox, season_folder_area);
@@ -104,7 +104,6 @@ fn draw_tasks_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.primary() .primary()
}; };
let tasks_table = ManagarrTable::new(Some(&mut app.data.sonarr_data.tasks), tasks_row_mapping) let tasks_table = ManagarrTable::new(Some(&mut app.data.sonarr_data.tasks), tasks_row_mapping)
.block(borderless_block())
.loading(app.is_loading) .loading(app.is_loading)
.margin(1) .margin(1)
.footer(help_footer) .footer(help_footer)
+17 -19
View File
@@ -1,8 +1,6 @@
use ratatui::prelude::Color; use crate::ui::THEME;
use ratatui::style::{Styled, Stylize}; use ratatui::style::{Styled, Stylize};
pub const COLOR_ORANGE: Color = Color::Rgb(255, 170, 66);
#[cfg(test)] #[cfg(test)]
#[path = "styles_tests.rs"] #[path = "styles_tests.rs"]
mod styles_tests; mod styles_tests;
@@ -42,31 +40,31 @@ where
} }
fn awaiting_import(self) -> T { 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 { fn indeterminate(self) -> T {
self.fg(COLOR_ORANGE) THEME.with(|theme| self.fg(theme.get().indeterminate.unwrap().color.unwrap()))
} }
fn default(self) -> T { fn default(self) -> T {
self.white() THEME.with(|theme| self.fg(theme.get().default.unwrap().color.unwrap()))
} }
fn downloaded(self) -> T { fn downloaded(self) -> T {
self.green() THEME.with(|theme| self.fg(theme.get().downloaded.unwrap().color.unwrap()))
} }
fn downloading(self) -> T { fn downloading(self) -> T {
self.magenta() THEME.with(|theme| self.fg(theme.get().downloading.unwrap().color.unwrap()))
} }
fn failure(self) -> T { fn failure(self) -> T {
self.red() THEME.with(|theme| self.fg(theme.get().failure.unwrap().color.unwrap()))
} }
fn help(self) -> T { fn help(self) -> T {
self.light_blue() THEME.with(|theme| self.fg(theme.get().help.unwrap().color.unwrap()))
} }
fn highlight(self) -> T { fn highlight(self) -> T {
@@ -74,38 +72,38 @@ where
} }
fn missing(self) -> T { fn missing(self) -> T {
self.red() THEME.with(|theme| self.fg(theme.get().missing.unwrap().color.unwrap()))
} }
fn primary(self) -> T { fn primary(self) -> T {
self.cyan() THEME.with(|theme| self.fg(theme.get().primary.unwrap().color.unwrap()))
} }
fn secondary(self) -> T { fn secondary(self) -> T {
self.yellow() THEME.with(|theme| self.fg(theme.get().secondary.unwrap().color.unwrap()))
} }
fn success(self) -> T { fn success(self) -> T {
self.green() THEME.with(|theme| self.fg(theme.get().success.unwrap().color.unwrap()))
} }
fn system_function(self) -> T { fn system_function(self) -> T {
self.yellow() THEME.with(|theme| self.fg(theme.get().system_function.unwrap().color.unwrap()))
} }
fn unmonitored(self) -> T { fn unmonitored(self) -> T {
self.gray() THEME.with(|theme| self.fg(theme.get().unmonitored.unwrap().color.unwrap()))
} }
fn unmonitored_missing(self) -> T { fn unmonitored_missing(self) -> T {
self.yellow() THEME.with(|theme| self.fg(theme.get().unmonitored_missing.unwrap().color.unwrap()))
} }
fn unreleased(self) -> T { fn unreleased(self) -> T {
self.light_cyan() THEME.with(|theme| self.fg(theme.get().unreleased.unwrap().color.unwrap()))
} }
fn warning(self) -> T { fn warning(self) -> T {
self.magenta() THEME.with(|theme| self.fg(theme.get().warning.unwrap().color.unwrap()))
} }
} }
+7 -4
View File
@@ -1,9 +1,9 @@
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::ui::styles::{ManagarrStyle, COLOR_ORANGE}; use crate::ui::styles::ManagarrStyle;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use ratatui::prelude::Modifier; use ratatui::prelude::Modifier;
use ratatui::style::{Style, Stylize}; use ratatui::style::{Color, Style, Stylize};
#[test] #[test]
fn test_new() { fn test_new() {
@@ -14,13 +14,16 @@ mod test {
fn test_style_awaiting_import() { fn test_style_awaiting_import() {
assert_eq!( assert_eq!(
Style::new().awaiting_import(), Style::new().awaiting_import(),
Style::new().fg(COLOR_ORANGE) Style::new().fg(Color::Rgb(255, 170, 66))
); );
} }
#[test] #[test]
fn test_style_indeterminate() { 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] #[test]
+284
View File
@@ -0,0 +1,284 @@
use crate::ui::builtin_themes::get_builtin_themes;
use anyhow::Result;
use derivative::Derivative;
use ratatui::style::Color;
use serde::{Deserialize, Deserializer, Serialize};
use std::str::FromStr;
use validate_theme_derive::ValidateTheme;
#[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<Color>,
#[derivative(Default(value = "Some(true)"))]
#[serde(default = "default_background_enabled")]
pub enabled: Option<bool>,
}
#[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<Color>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, Derivative, ValidateTheme)]
#[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<Background>,
#[validate]
#[serde(default = "default_awaiting_import_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Rgb(255, 170, 66)) })"))]
pub awaiting_import: Option<Style>,
#[validate]
#[serde(default = "default_indeterminate_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Rgb(255, 170, 66)) })"))]
pub indeterminate: Option<Style>,
#[validate]
#[serde(default = "default_default_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::White) })"))]
pub default: Option<Style>,
#[validate]
#[serde(default = "default_downloaded_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Green) })"))]
pub downloaded: Option<Style>,
#[validate]
#[serde(default = "default_downloading_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Magenta) })"))]
pub downloading: Option<Style>,
#[validate]
#[serde(default = "default_failure_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Red) })"))]
pub failure: Option<Style>,
#[validate]
#[serde(default = "default_help_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::LightBlue) })"))]
pub help: Option<Style>,
#[validate]
#[serde(default = "default_missing_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Red) })"))]
pub missing: Option<Style>,
#[validate]
#[serde(default = "default_primary_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Cyan) })"))]
pub primary: Option<Style>,
#[validate]
#[serde(default = "default_secondary_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Yellow) })"))]
pub secondary: Option<Style>,
#[validate]
#[serde(default = "default_success_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Green) })"))]
pub success: Option<Style>,
#[validate]
#[serde(default = "default_system_function_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Yellow) })"))]
pub system_function: Option<Style>,
#[validate]
#[serde(default = "default_unmonitored_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Gray) })"))]
pub unmonitored: Option<Style>,
#[validate]
#[serde(default = "default_unmonitored_missing_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Yellow) })"))]
pub unmonitored_missing: Option<Style>,
#[validate]
#[serde(default = "default_unreleased_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::LightCyan) })"))]
pub unreleased: Option<Style>,
#[validate]
#[serde(default = "default_warning_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Magenta) })"))]
pub warning: Option<Style>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[cfg_attr(test, derive(PartialEq, Eq))]
pub struct ThemeDefinition {
pub name: String,
#[serde(default)]
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> {
Some(Color::Rgb(35, 50, 55))
}
fn default_background_enabled() -> Option<bool> {
Some(true)
}
fn default_background() -> Option<Background> {
Some(Background {
color: default_background_color(),
enabled: Some(true),
})
}
fn default_awaiting_import_style() -> Option<Style> {
Some(Style {
color: Some(Color::Rgb(255, 170, 66)),
})
}
fn default_indeterminate_style() -> Option<Style> {
Some(Style {
color: Some(Color::Rgb(255, 170, 66)),
})
}
fn default_default_style() -> Option<Style> {
Some(Style {
color: Some(Color::White),
})
}
fn default_downloaded_style() -> Option<Style> {
Some(Style {
color: Some(Color::Green),
})
}
fn default_downloading_style() -> Option<Style> {
Some(Style {
color: Some(Color::Magenta),
})
}
fn default_failure_style() -> Option<Style> {
Some(Style {
color: Some(Color::Red),
})
}
fn default_help_style() -> Option<Style> {
Some(Style {
color: Some(Color::LightBlue),
})
}
fn default_missing_style() -> Option<Style> {
Some(Style {
color: Some(Color::Red),
})
}
fn default_primary_style() -> Option<Style> {
Some(Style {
color: Some(Color::Cyan),
})
}
fn default_secondary_style() -> Option<Style> {
Some(Style {
color: Some(Color::Yellow),
})
}
fn default_success_style() -> Option<Style> {
Some(Style {
color: Some(Color::Green),
})
}
fn default_system_function_style() -> Option<Style> {
Some(Style {
color: Some(Color::Yellow),
})
}
fn default_unmonitored_style() -> Option<Style> {
Some(Style {
color: Some(Color::Gray),
})
}
fn default_unmonitored_missing_style() -> Option<Style> {
Some(Style {
color: Some(Color::Yellow),
})
}
fn default_unreleased_style() -> Option<Style> {
Some(Style {
color: Some(Color::LightCyan),
})
}
fn default_warning_style() -> Option<Style> {
Some(Style {
color: Some(Color::Magenta),
})
}
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>
where
D: Deserializer<'de>,
{
let s: Option<String> = Option::deserialize(deserializer)?;
match s {
Some(s) => Color::from_str(&s)
.map_err(serde::de::Error::custom)
.map(Some),
None => Ok(None),
}
}
fn serialize_color_str<S>(color: &Option<Color>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&color.unwrap().to_string())
}
+376
View File
@@ -0,0 +1,376 @@
mod tests {
use crate::ui::builtin_themes::{dracula_theme, eldritch_theme, watermelon_dark_theme};
use crate::ui::theme::{Background, Style, Theme, ThemeDefinition, ThemeDefinitionsWrapper};
use pretty_assertions::{assert_eq, assert_str_eq};
use ratatui::style::Color;
#[test]
fn test_background_default() {
let expected_background = Background {
enabled: Some(true),
color: Some(Color::Rgb(35, 50, 55)),
};
assert_eq!(Background::default(), expected_background);
}
#[test]
fn test_theme_default() {
let expected_theme = Theme {
background: Some(Background {
enabled: Some(true),
color: Some(Color::Rgb(35, 50, 55)),
}),
awaiting_import: Some(Style {
color: Some(Color::Rgb(255, 170, 66)),
}),
indeterminate: Some(Style {
color: Some(Color::Rgb(255, 170, 66)),
}),
default: Some(Style {
color: Some(Color::White),
}),
downloaded: Some(Style {
color: Some(Color::Green),
}),
downloading: Some(Style {
color: Some(Color::Magenta),
}),
failure: Some(Style {
color: Some(Color::Red),
}),
help: Some(Style {
color: Some(Color::LightBlue),
}),
missing: Some(Style {
color: Some(Color::Red),
}),
primary: Some(Style {
color: Some(Color::Cyan),
}),
secondary: Some(Style {
color: Some(Color::Yellow),
}),
success: Some(Style {
color: Some(Color::Green),
}),
system_function: Some(Style {
color: Some(Color::Yellow),
}),
unmonitored: Some(Style {
color: Some(Color::Gray),
}),
unmonitored_missing: Some(Style {
color: Some(Color::Yellow),
}),
unreleased: Some(Style {
color: Some(Color::LightCyan),
}),
warning: Some(Style {
color: Some(Color::Magenta),
}),
};
assert_eq!(Theme::default(), expected_theme);
}
#[test]
fn test_default_theme_definition() {
let expected_theme_definition = ThemeDefinition {
name: String::new(),
theme: Theme::default(),
};
assert_eq!(ThemeDefinition::default(), expected_theme_definition);
}
#[test]
fn test_deserialization_defaults_to_using_default_theme_values_when_missing() {
let theme_yaml = r#""#;
let theme: Theme = serde_yaml::from_str(theme_yaml).unwrap();
assert_eq!(theme, Theme::default());
}
#[test]
fn test_deserialization_does_not_overwrite_non_empty_fields_with_default_values() {
let theme_yaml = r###"
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: Theme = serde_yaml::from_str(theme_yaml).unwrap();
let expected_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, expected_theme);
}
#[test]
fn test_theme_definitions_wrapper_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_theme(),
},
ThemeDefinition {
name: "dracula".to_owned(),
theme: dracula_theme(),
},
ThemeDefinition {
name: "eldritch".to_owned(),
theme: eldritch_theme(),
},
],
};
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);
}
}
+22 -11
View File
@@ -1,18 +1,25 @@
use crate::ui::styles::ManagarrStyle; use crate::ui::styles::ManagarrStyle;
use crate::ui::THEME;
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Style, Stylize}; use ratatui::style::{Style, Stylize};
use ratatui::symbols; use ratatui::symbols;
use ratatui::text::{Line, Span, Text}; use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, BorderType, Borders, LineGauge, ListItem, Paragraph, Wrap}; use ratatui::widgets::{Block, BorderType, Borders, LineGauge, ListItem, Paragraph, Wrap};
pub const COLOR_TEAL: Color = Color::Rgb(35, 50, 55);
#[cfg(test)] #[cfg(test)]
#[path = "utils_tests.rs"] #[path = "utils_tests.rs"]
mod utils_tests; mod utils_tests;
pub fn background_block<'a>() -> Block<'a> { pub fn background_block<'a>() -> Block<'a> {
Block::new().white().bg(COLOR_TEAL) THEME.with(|theme| {
let background = theme.get().background.unwrap();
if background.enabled.unwrap() {
Block::new().white().bg(background.color.unwrap())
} else {
Block::new().white()
}
})
} }
pub fn layout_block<'a>() -> Block<'a> { pub fn layout_block<'a>() -> Block<'a> {
@@ -30,11 +37,11 @@ pub fn layout_block_top_border_with_title(title_span: Span<'_>) -> Block<'_> {
} }
pub fn layout_block_top_border<'a>() -> Block<'a> { pub fn layout_block_top_border<'a>() -> Block<'a> {
Block::new().borders(Borders::TOP) Block::new().borders(Borders::TOP).default()
} }
pub fn layout_block_bottom_border<'a>() -> Block<'a> { pub fn layout_block_bottom_border<'a>() -> Block<'a> {
Block::new().borders(Borders::BOTTOM) Block::new().borders(Borders::BOTTOM).default()
} }
pub fn layout_paragraph_borderless(string: &str) -> Paragraph<'_> { pub fn layout_paragraph_borderless(string: &str) -> Paragraph<'_> {
@@ -47,7 +54,7 @@ pub fn layout_paragraph_borderless(string: &str) -> Paragraph<'_> {
} }
pub fn borderless_block<'a>() -> Block<'a> { pub fn borderless_block<'a>() -> Block<'a> {
Block::new() Block::new().default()
} }
pub fn style_block_highlight(is_selected: bool) -> Style { pub fn style_block_highlight(is_selected: bool) -> Style {
@@ -62,16 +69,20 @@ pub fn title_style(title: &str) -> Span<'_> {
format!(" {title} ").bold() format!(" {title} ").bold()
} }
pub fn title_block(title: &str) -> Block<'_> { pub fn unstyled_title_block(title: &str) -> Block<'_> {
layout_block_with_title(title_style(title)) layout_block_with_title(title_style(title))
} }
pub fn title_block(title: &str) -> Block<'_> {
unstyled_title_block(title).default()
}
pub fn title_block_centered(title: &str) -> Block<'_> { pub fn title_block_centered(title: &str) -> Block<'_> {
title_block(title).title_alignment(Alignment::Center) title_block(title).title_alignment(Alignment::Center)
} }
pub fn logo_block<'a>() -> Block<'a> { pub fn logo_block<'a>() -> Block<'a> {
layout_block().title(Span::styled( layout_block().default().title(Span::styled(
" Managarr - A Servarr management TUI ", " Managarr - A Servarr management TUI ",
Style::new().magenta().bold().italic(), Style::new().magenta().bold().italic(),
)) ))
@@ -98,7 +109,7 @@ pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
pub fn line_gauge_with_title(title: &str, ratio: f64) -> LineGauge<'_> { pub fn line_gauge_with_title(title: &str, ratio: f64) -> LineGauge<'_> {
LineGauge::new() LineGauge::new()
.block(Block::new().title(title)) .block(Block::new().title(title))
.filled_style(Style::new().cyan()) .filled_style(Style::new().primary())
.line_set(symbols::line::THICK) .line_set(symbols::line::THICK)
.ratio(ratio) .ratio(ratio)
.label(Line::from(format!("{:.0}%", ratio * 100.0))) .label(Line::from(format!("{:.0}%", ratio * 100.0)))
@@ -107,7 +118,7 @@ pub fn line_gauge_with_title(title: &str, ratio: f64) -> LineGauge<'_> {
pub fn line_gauge_with_label(title: &str, ratio: f64) -> LineGauge<'_> { pub fn line_gauge_with_label(title: &str, ratio: f64) -> LineGauge<'_> {
LineGauge::new() LineGauge::new()
.block(Block::new()) .block(Block::new())
.filled_style(Style::new().cyan()) .filled_style(Style::new().primary())
.line_set(symbols::line::THICK) .line_set(symbols::line::THICK)
.ratio(ratio) .ratio(ratio)
.label(Line::from(format!("{title}: {:.0}%", ratio * 100.0))) .label(Line::from(format!("{title}: {:.0}%", ratio * 100.0)))
+37 -23
View File
@@ -1,10 +1,11 @@
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::ui::styles::ManagarrStyle;
use crate::ui::utils::{ use crate::ui::utils::{
borderless_block, centered_rect, convert_to_minutes_hours_days, decorate_peer_style, borderless_block, centered_rect, convert_to_minutes_hours_days, decorate_peer_style,
get_width_from_percentage, layout_block, layout_block_bottom_border, layout_block_top_border, get_width_from_percentage, layout_block, layout_block_bottom_border, layout_block_top_border,
layout_block_top_border_with_title, layout_block_with_title, logo_block, style_block_highlight, layout_block_top_border_with_title, layout_block_with_title, logo_block, style_block_highlight,
style_log_list_item, title_block, title_block_centered, title_style, style_log_list_item, title_block, title_block_centered, title_style, unstyled_title_block,
}; };
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use ratatui::layout::{Alignment, Rect}; use ratatui::layout::{Alignment, Rect};
@@ -17,7 +18,7 @@ mod test {
fn test_layout_block() { fn test_layout_block() {
assert_eq!( assert_eq!(
layout_block(), layout_block(),
Block::default() Block::new()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
); );
@@ -27,11 +28,11 @@ mod test {
fn test_layout_block_with_title() { fn test_layout_block_with_title() {
let title_span = Span::styled( let title_span = Span::styled(
"title", "title",
Style::default() Style::new()
.fg(Color::DarkGray) .fg(Color::DarkGray)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
); );
let expected_block = Block::default() let expected_block = Block::new()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.title(title_span.clone()); .title(title_span.clone());
@@ -43,11 +44,12 @@ mod test {
fn test_layout_block_top_border_with_title() { fn test_layout_block_top_border_with_title() {
let title_span = Span::styled( let title_span = Span::styled(
"title", "title",
Style::default() Style::new()
.fg(Color::DarkGray) .fg(Color::DarkGray)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
); );
let expected_block = Block::default() let expected_block = Block::new()
.default()
.borders(Borders::TOP) .borders(Borders::TOP)
.title(title_span.clone()); .title(title_span.clone());
@@ -61,7 +63,7 @@ mod test {
fn test_layout_block_top_border() { fn test_layout_block_top_border() {
assert_eq!( assert_eq!(
layout_block_top_border(), layout_block_top_border(),
Block::default().borders(Borders::TOP) Block::new().borders(Borders::TOP).default()
); );
} }
@@ -69,48 +71,58 @@ mod test {
fn test_layout_block_bottom_border() { fn test_layout_block_bottom_border() {
assert_eq!( assert_eq!(
layout_block_bottom_border(), layout_block_bottom_border(),
Block::default().borders(Borders::BOTTOM) Block::new().borders(Borders::BOTTOM).default()
); );
} }
#[test] #[test]
fn test_borderless_block() { fn test_borderless_block() {
assert_eq!(borderless_block(), Block::default()); assert_eq!(borderless_block(), Block::new().default());
} }
#[test] #[test]
fn test_style_button_highlight_selected() { fn test_style_button_highlight_selected() {
let expected_style = Style::default() let expected_style = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
assert_eq!(style_block_highlight(true), expected_style); assert_eq!(style_block_highlight(true), expected_style);
} }
#[test] #[test]
fn test_style_button_highlight_unselected() { fn test_style_button_highlight_unselected() {
let expected_style = Style::default() let expected_style = Style::new().fg(Color::White).add_modifier(Modifier::BOLD);
.fg(Color::White)
.add_modifier(Modifier::BOLD);
assert_eq!(style_block_highlight(false), expected_style); assert_eq!(style_block_highlight(false), expected_style);
} }
#[test] #[test]
fn test_title_style() { fn test_title_style() {
let expected_span = Span::styled(" test ", Style::default().add_modifier(Modifier::BOLD)); let expected_span = Span::styled(" test ", Style::new().add_modifier(Modifier::BOLD));
assert_eq!(title_style("test"), expected_span); assert_eq!(title_style("test"), expected_span);
} }
#[test] #[test]
fn test_title_block() { fn test_unstyled_title_block() {
let expected_block = Block::default() let expected_block = Block::new()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.title(Span::styled( .title(Span::styled(
" test ", " test ",
Style::default().add_modifier(Modifier::BOLD), Style::new().add_modifier(Modifier::BOLD),
));
assert_eq!(unstyled_title_block("test"), expected_block);
}
#[test]
fn test_title_block() {
let expected_block = Block::new()
.default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" test ",
Style::new().add_modifier(Modifier::BOLD),
)); ));
assert_eq!(title_block("test"), expected_block); assert_eq!(title_block("test"), expected_block);
@@ -118,12 +130,13 @@ mod test {
#[test] #[test]
fn test_title_block_centered() { fn test_title_block_centered() {
let expected_block = Block::default() let expected_block = Block::new()
.default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.title(Span::styled( .title(Span::styled(
" test ", " test ",
Style::default().add_modifier(Modifier::BOLD), Style::new().add_modifier(Modifier::BOLD),
)) ))
.title_alignment(Alignment::Center); .title_alignment(Alignment::Center);
@@ -132,12 +145,13 @@ mod test {
#[test] #[test]
fn test_logo_block() { fn test_logo_block() {
let expected_block = Block::default() let expected_block = Block::new()
.default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.title(Span::styled( .title(Span::styled(
" Managarr - A Servarr management TUI ", " Managarr - A Servarr management TUI ",
Style::default() Style::new()
.fg(Color::Magenta) .fg(Color::Magenta)
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.add_modifier(Modifier::ITALIC), .add_modifier(Modifier::ITALIC),
+4 -2
View File
@@ -3,7 +3,9 @@ use super::message::Message;
use super::popup::Size; use super::popup::Size;
use crate::models::stateful_table::StatefulTable; use crate::models::stateful_table::StatefulTable;
use crate::ui::styles::ManagarrStyle; use crate::ui::styles::ManagarrStyle;
use crate::ui::utils::{centered_rect, layout_block_top_border, title_block_centered}; use crate::ui::utils::{
borderless_block, centered_rect, layout_block_top_border, title_block_centered,
};
use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::loading_block::LoadingBlock;
use crate::ui::widgets::popup::Popup; use crate::ui::widgets::popup::Popup;
use crate::ui::widgets::selectable_list::SelectableList; use crate::ui::widgets::selectable_list::SelectableList;
@@ -68,7 +70,7 @@ where
row_mapper, row_mapper,
footer: None, footer: None,
footer_alignment: Alignment::Left, footer_alignment: Alignment::Left,
block: Block::new(), block: borderless_block(),
margin: 0, margin: 0,
is_loading: false, is_loading: false,
highlight_rows: true, highlight_rows: true,
+7 -6
View File
@@ -3,11 +3,12 @@ mod tests {
use crate::models::stateful_list::StatefulList; use crate::models::stateful_list::StatefulList;
use crate::models::stateful_table::{SortOption, StatefulTable}; use crate::models::stateful_table::{SortOption, StatefulTable};
use crate::models::{HorizontallyScrollableText, Scrollable}; use crate::models::{HorizontallyScrollableText, Scrollable};
use crate::ui::utils::borderless_block;
use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::managarr_table::ManagarrTable;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use ratatui::layout::{Alignment, Constraint}; use ratatui::layout::{Alignment, Constraint};
use ratatui::text::Text; use ratatui::text::Text;
use ratatui::widgets::{Block, Cell, Row}; use ratatui::widgets::{Cell, Row};
use std::sync::atomic::AtomicUsize; use std::sync::atomic::AtomicUsize;
#[test] #[test]
@@ -26,7 +27,7 @@ mod tests {
assert_eq!(managarr_table.constraints, Vec::new()); assert_eq!(managarr_table.constraints, Vec::new());
assert_eq!(managarr_table.footer, None); assert_eq!(managarr_table.footer, None);
assert_eq!(managarr_table.footer_alignment, Alignment::Left); assert_eq!(managarr_table.footer_alignment, Alignment::Left);
assert_eq!(managarr_table.block, Block::new()); assert_eq!(managarr_table.block, borderless_block());
assert_eq!(managarr_table.margin, 0); assert_eq!(managarr_table.margin, 0);
assert!(!managarr_table.is_loading); assert!(!managarr_table.is_loading);
assert!(managarr_table.highlight_rows); assert!(managarr_table.highlight_rows);
@@ -62,7 +63,7 @@ mod tests {
assert_eq!(managarr_table.constraints, Vec::new()); assert_eq!(managarr_table.constraints, Vec::new());
assert_eq!(managarr_table.footer, None); assert_eq!(managarr_table.footer, None);
assert_eq!(managarr_table.footer_alignment, Alignment::Left); assert_eq!(managarr_table.footer_alignment, Alignment::Left);
assert_eq!(managarr_table.block, Block::new()); assert_eq!(managarr_table.block, borderless_block());
assert_eq!(managarr_table.margin, 0); assert_eq!(managarr_table.margin, 0);
assert!(!managarr_table.is_loading); assert!(!managarr_table.is_loading);
assert!(managarr_table.highlight_rows); assert!(managarr_table.highlight_rows);
@@ -98,7 +99,7 @@ mod tests {
assert_eq!(managarr_table.constraints, Vec::new()); assert_eq!(managarr_table.constraints, Vec::new());
assert_eq!(managarr_table.footer, None); assert_eq!(managarr_table.footer, None);
assert_eq!(managarr_table.footer_alignment, Alignment::Left); assert_eq!(managarr_table.footer_alignment, Alignment::Left);
assert_eq!(managarr_table.block, Block::new()); assert_eq!(managarr_table.block, borderless_block());
assert_eq!(managarr_table.margin, 0); assert_eq!(managarr_table.margin, 0);
assert!(!managarr_table.is_loading); assert!(!managarr_table.is_loading);
assert!(managarr_table.highlight_rows); assert!(managarr_table.highlight_rows);
@@ -131,7 +132,7 @@ mod tests {
assert_eq!(managarr_table.constraints, Vec::new()); assert_eq!(managarr_table.constraints, Vec::new());
assert_eq!(managarr_table.footer, None); assert_eq!(managarr_table.footer, None);
assert_eq!(managarr_table.footer_alignment, Alignment::Left); assert_eq!(managarr_table.footer_alignment, Alignment::Left);
assert_eq!(managarr_table.block, Block::new()); assert_eq!(managarr_table.block, borderless_block());
assert_eq!(managarr_table.margin, 0); assert_eq!(managarr_table.margin, 0);
assert!(!managarr_table.is_loading); assert!(!managarr_table.is_loading);
assert!(managarr_table.highlight_rows); assert!(managarr_table.highlight_rows);
@@ -164,7 +165,7 @@ mod tests {
assert_eq!(managarr_table.table_headers, Vec::<String>::new()); assert_eq!(managarr_table.table_headers, Vec::<String>::new());
assert_eq!(managarr_table.footer, None); assert_eq!(managarr_table.footer, None);
assert_eq!(managarr_table.footer_alignment, Alignment::Left); assert_eq!(managarr_table.footer_alignment, Alignment::Left);
assert_eq!(managarr_table.block, Block::new()); assert_eq!(managarr_table.block, borderless_block());
assert_eq!(managarr_table.margin, 0); assert_eq!(managarr_table.margin, 0);
assert!(!managarr_table.is_loading); assert!(!managarr_table.is_loading);
assert!(managarr_table.highlight_rows); assert!(managarr_table.highlight_rows);
+23 -1
View File
@@ -21,6 +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::ThemeDefinitionsWrapper;
#[cfg(test)] #[cfg(test)]
#[path = "utils_tests.rs"] #[path = "utils_tests.rs"]
@@ -140,11 +141,32 @@ fn colorize_log_line(line: &str, re: &Regex) -> String {
} }
pub(super) fn load_config(path: &str) -> Result<AppConfig> { pub(super) fn load_config(path: &str) -> Result<AppConfig> {
let file = File::open(path).map_err(|e| anyhow!(e))?; match File::open(path).map_err(|e| anyhow!(e)) {
Ok(file) => {
let reader = BufReader::new(file); let reader = BufReader::new(file);
let config = serde_yaml::from_reader(reader)?; let config = serde_yaml::from_reader(reader)?;
Ok(config) Ok(config)
} }
Err(e) => {
log_and_print_error(format!("Unable to open config file: {e:?}"));
process::exit(1);
}
}
}
pub(super) fn load_theme_config(path: &str) -> Result<ThemeDefinitionsWrapper> {
match File::open(path).map_err(|e| anyhow!(e)) {
Ok(file) => {
let reader = BufReader::new(file);
let theme_config = serde_yaml::from_reader(reader)?;
Ok(theme_config)
}
Err(e) => {
log_and_print_error(format!("Unable to open theme file: {e:?}"));
process::exit(1);
}
}
}
pub(super) fn build_network_client(config: &AppConfig) -> Client { pub(super) fn build_network_client(config: &AppConfig) -> Client {
let mut client_builder = Client::builder() let mut client_builder = Client::builder()
@@ -8,7 +8,7 @@ fn test_derive_enum_display_style() {
} }
#[derive(EnumDisplayStyle)] #[derive(EnumDisplayStyle)]
pub enum TestEnum { enum TestEnum {
#[display_style(name = "Testing 123")] #[display_style(name = "Testing 123")]
Test, Test,
Ignored, Ignored,
@@ -0,0 +1,35 @@
use validate_theme_derive::ValidateTheme;
#[test]
fn test_validate_theme_derive() {
let theme = Theme {
name: "test".to_string(),
good: Some(Style {
color: "Green".to_owned(),
}),
bad: Some(Style {
color: "Red".to_owned(),
}),
ugly: Some(Style {
color: "Magenta".to_owned(),
}),
};
theme.validate();
}
#[allow(dead_code)]
struct Style {
color: String,
}
#[allow(dead_code)]
#[derive(ValidateTheme)]
struct Theme {
pub name: String,
#[validate]
pub good: Option<Style>,
#[validate]
pub bad: Option<Style>,
pub ugly: Option<Style>,
}
+123
View File
@@ -0,0 +1,123 @@
# 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)
![sonarr-library](./default/sonarr_library.png)
### [Dracula](./dracula/README.md)
![sonarr-library](./dracula/sonarr_library.png)
### [Eldritch](./eldritch/README.md)
![sonarr-library](./eldritch/sonarr_library.png)
### [Watermelon Dark](./watermelon-dark/README.md)
![sonarr-library](./watermelon-dark/sonarr_library.png)
## 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:
# Disable for transparent backgrounds
enabled: true
# Color of the full system background
color: "#233237"
awaiting_import:
# Color for items awaiting import
color: "#FFAA42"
indeterminate:
# Color for when item status is unknown
color: "#FFAA42"
default:
# Default color for text and uncolored elements
color: "#FFFFFF"
downloaded:
# Color for downloaded items (when monitored)
color: "#00FF00"
downloading:
# Color for items currently downloading
color: "#762671"
failure:
# Color for errors, no seeders, disabled indexers,
# failed indexer test results, etc.
color: "#DE382B"
help:
# Color for help text like hotkeys and tooltips
color: "#00FFFF"
missing:
# Color for missing items
color: "#DE382B"
primary:
# Primary color for table/list items (without custom coloring),
# selected UI elements (e.g. table/list items, current tab, etc.),
# gauge bars, etc.
color: "#2CB5E9"
secondary:
# Color for selected tabs, Warn log events, Paragraph headers (e.g. "Name: "),
# and uncolored Message box contents
color: "#FFC706"
success:
# Color for downloaded and imported and available items,
# good peer styling (more seeders than leechers),
# passing indexer test results, and enabled indexers
color: "#39B54A"
system_function:
# Color for system functions like selected UI elements (e.g. checkboxes, yes/no prompts),
# or loading spinner
color: "#FFC706"
unmonitored:
# Color for unmonitored items
color: "#808080"
unmonitored_missing:
# Color for unmonitored items that are also missing
color: "#FFC706"
unreleased:
# Color for unreleased items
color: "#00FFFF"
warning:
# Color for fewer seeders than leechers
color: "#FF00FF"
```
**Note:** Bear in mind that while some colors may currently affect few UI elements, Managarr is still being built
and thus these colors may affect more elements in the future.
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
```
+8
View File
@@ -0,0 +1,8 @@
# Managarr Default Theme
The [themes.yml](./themes.yml) file in this directory corresponds to the theme configuration for the default Managarr theme.
## Screenshots
![sonarr-library](./sonarr_library.png)
![manual-episode-search](./manual_episode_search.png)
![radarr-system](./radarr_system.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

+37
View File
@@ -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"
+8
View File
@@ -0,0 +1,8 @@
# Managarr Dracula Theme
The [themes.yml](./themes.yml) file in this directory corresponds to the theme configuration for the Dracula Managarr theme.
## Screenshots
![sonarr-library](./sonarr_library.png)
![manual-episode-search](./manual_episode_search.png)
![radarr-system](./radarr_system.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

+29
View File
@@ -0,0 +1,29 @@
- name: dracula
theme:
background:
enabled: true
color: "#232326"
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"
+8
View File
@@ -0,0 +1,8 @@
# Managarr Eldritch Theme
The [themes.yml](./themes.yml) file in this directory corresponds to the theme configuration for the Eldritch Managarr theme.
## Screenshots
![sonarr-library](./sonarr_library.png)
![manual-episode-search](./manual_episode_search.png)
![radarr-system](./radarr_system.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

+29
View File
@@ -0,0 +1,29 @@
- name: eldritch
theme:
background:
enabled: true
color: "#212337"
default:
color: "#ebfafa"
downloaded:
color: "#37f499"
downloading:
color: "#f7c67f"
failure:
color: "#f16c75"
missing:
color: "#f7c67f"
unmonitored_missing:
color: "#7081d0"
help:
color: "#7081d0"
primary:
color: "#f265b5"
secondary:
color: "#04d1f9"
success:
color: "#37f499"
warning:
color: "#f1fc79"
unreleased:
color: "#ebfafa"
+9
View File
@@ -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
![sonarr-library](./sonarr_library.png)
![manual-episode-search](./manual_episode_search.png)
![radarr-system](./radarr_system.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

+16
View File
@@ -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"