feat: Initial support for custom user-defined themes

This commit is contained in:
2025-03-04 18:09:09 -07:00
parent 847de75713
commit 5cb60c317d
11 changed files with 582 additions and 67 deletions
+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>>,
} }
+59 -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, ThemeDefinition};
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_THEME_FILE",
help = "The Managarr theme file to use"
)]
theme_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.theme_file,
args.theme.unwrap_or(theme_name.unwrap_or_default()),
)
.await?;
} }
} }
@@ -174,7 +198,32 @@ async fn start_networking(
} }
} }
async fn start_ui(app: &Arc<Mutex<App<'_>>>) -> Result<()> { async fn start_ui(
app: &Arc<Mutex<App<'_>>>,
theme_file_arg: &Option<PathBuf>,
theme_name: String,
) -> Result<()> {
let theme_definitions = if let Some(ref theme_file) = theme_file_arg {
load_theme_config(theme_file.to_str().expect("Invalid theme file specified"))?
} else {
confy::load("managarr", "theme").unwrap_or_else(|_| vec![ThemeDefinition::default()])
};
let theme = if !theme_name.is_empty() {
let theme_definition = theme_definitions.iter().find(|t| t.name == theme_name);
if theme_definition.is_none() {
log_and_print_error(format!("The specified theme was not found: {theme_name}"));
process::exit(1);
}
theme_definition.unwrap().theme
} else {
debug!("No theme specified, using default theme");
Theme::default()
};
debug!("Managarr loaded using theme: {theme:?}");
THEME.set(theme);
let mut stdout = io::stdout(); let mut stdout = io::stdout();
enable_raw_mode()?; enable_raw_mode()?;
+6
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,6 +16,7 @@ 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,
}; };
@@ -24,10 +26,14 @@ use crate::ui::widgets::popup::Size;
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;
+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]
+233
View File
@@ -0,0 +1,233 @@
use anyhow::Result;
use derivative::Derivative;
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[cfg(test)]
#[path = "theme_tests.rs"]
mod theme_tests;
#[derive(Debug, Deserialize, Serialize, Clone, Copy, Derivative)]
#[derivative(Default)]
#[cfg_attr(test, derive(PartialEq, Eq))]
pub struct Background {
#[serde(
deserialize_with = "deserialize_color_str",
serialize_with = "serialize_color_str",
default = "default_background_color"
)]
#[derivative(Default(value = "Some(Color::Rgb(35, 50, 55))"))]
pub color: Option<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)]
#[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>,
#[serde(default = "default_awaiting_import_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Rgb(255, 170, 66)) })"))]
pub awaiting_import: Option<Style>,
#[serde(default = "default_indeterminate_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Rgb(255, 170, 66)) })"))]
pub indeterminate: Option<Style>,
#[serde(default = "default_default_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::White) })"))]
pub default: Option<Style>,
#[serde(default = "default_downloaded_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Green) })"))]
pub downloaded: Option<Style>,
#[serde(default = "default_downloading_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Magenta) })"))]
pub downloading: Option<Style>,
#[serde(default = "default_failure_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Red) })"))]
pub failure: Option<Style>,
#[serde(default = "default_help_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::LightBlue) })"))]
pub help: Option<Style>,
#[serde(default = "default_missing_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Red) })"))]
pub missing: Option<Style>,
#[serde(default = "default_primary_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Cyan) })"))]
pub primary: Option<Style>,
#[serde(default = "default_secondary_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Yellow) })"))]
pub secondary: Option<Style>,
#[serde(default = "default_success_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Green) })"))]
pub success: Option<Style>,
#[serde(default = "default_system_function_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Yellow) })"))]
pub system_function: Option<Style>,
#[serde(default = "default_unmonitored_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Gray) })"))]
pub unmonitored: Option<Style>,
#[serde(default = "default_unmonitored_missing_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::Yellow) })"))]
pub unmonitored_missing: Option<Style>,
#[serde(default = "default_unreleased_style")]
#[derivative(Default(value = "Some(Style { color: Some(Color::LightCyan) })"))]
pub unreleased: Option<Style>,
#[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,
}
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),
})
}
fn deserialize_color_str<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
where
D: serde::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())
}
+191
View File
@@ -0,0 +1,191 @@
mod tests {
use crate::ui::theme::{Background, Style, Theme, ThemeDefinition};
use pretty_assertions::assert_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);
}
}
+17 -9
View File
@@ -1,22 +1,30 @@
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> {
Block::new() Block::new()
.default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
} }
@@ -30,11 +38,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 +55,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 {
@@ -98,7 +106,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 +115,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)))
+24 -21
View File
@@ -1,5 +1,6 @@
#[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,
@@ -17,7 +18,8 @@ mod test {
fn test_layout_block() { fn test_layout_block() {
assert_eq!( assert_eq!(
layout_block(), layout_block(),
Block::default() Block::new()
.default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
); );
@@ -27,11 +29,12 @@ 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()
.default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.title(title_span.clone()); .title(title_span.clone());
@@ -43,11 +46,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 +65,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 +73,45 @@ 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_title_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(
" test ", " test ",
Style::default().add_modifier(Modifier::BOLD), Style::new().add_modifier(Modifier::BOLD),
)); ));
assert_eq!(title_block("test"), expected_block); assert_eq!(title_block("test"), expected_block);
@@ -118,12 +119,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 +134,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),
+26 -4
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::ThemeDefinition;
#[cfg(test)] #[cfg(test)]
#[path = "utils_tests.rs"] #[path = "utils_tests.rs"]
@@ -140,10 +141,31 @@ 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)) {
let reader = BufReader::new(file); Ok(file) => {
let config = serde_yaml::from_reader(reader)?; let reader = BufReader::new(file);
Ok(config) let config = serde_yaml::from_reader(reader)?;
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<Vec<ThemeDefinition>> {
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 {