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
+6
View File
@@ -1,3 +1,4 @@
use std::cell::Cell;
use std::sync::atomic::Ordering;
use ratatui::layout::{Constraint, Flex, Layout, Rect};
@@ -15,6 +16,7 @@ use crate::app::App;
use crate::models::{HorizontallyScrollableText, Route, TabState};
use crate::ui::radarr_ui::RadarrUi;
use crate::ui::styles::ManagarrStyle;
use crate::ui::theme::Theme;
use crate::ui::utils::{
background_block, borderless_block, centered_rect, logo_block, title_block, title_block_centered,
};
@@ -24,10 +26,14 @@ use crate::ui::widgets::popup::Size;
mod radarr_ui;
mod sonarr_ui;
mod styles;
pub mod theme;
mod utils;
mod widgets;
static HIGHLIGHT_SYMBOL: &str = "=> ";
thread_local! {
pub static THEME: Cell<Theme> = Cell::new(Theme::default());
}
pub trait DrawUi {
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};
pub const COLOR_ORANGE: Color = Color::Rgb(255, 170, 66);
#[cfg(test)]
#[path = "styles_tests.rs"]
mod styles_tests;
@@ -42,31 +40,31 @@ where
}
fn awaiting_import(self) -> T {
self.fg(COLOR_ORANGE)
THEME.with(|theme| self.fg(theme.get().awaiting_import.unwrap().color.unwrap()))
}
fn indeterminate(self) -> T {
self.fg(COLOR_ORANGE)
THEME.with(|theme| self.fg(theme.get().indeterminate.unwrap().color.unwrap()))
}
fn default(self) -> T {
self.white()
THEME.with(|theme| self.fg(theme.get().default.unwrap().color.unwrap()))
}
fn downloaded(self) -> T {
self.green()
THEME.with(|theme| self.fg(theme.get().downloaded.unwrap().color.unwrap()))
}
fn downloading(self) -> T {
self.magenta()
THEME.with(|theme| self.fg(theme.get().downloading.unwrap().color.unwrap()))
}
fn failure(self) -> T {
self.red()
THEME.with(|theme| self.fg(theme.get().failure.unwrap().color.unwrap()))
}
fn help(self) -> T {
self.light_blue()
THEME.with(|theme| self.fg(theme.get().help.unwrap().color.unwrap()))
}
fn highlight(self) -> T {
@@ -74,38 +72,38 @@ where
}
fn missing(self) -> T {
self.red()
THEME.with(|theme| self.fg(theme.get().missing.unwrap().color.unwrap()))
}
fn primary(self) -> T {
self.cyan()
THEME.with(|theme| self.fg(theme.get().primary.unwrap().color.unwrap()))
}
fn secondary(self) -> T {
self.yellow()
THEME.with(|theme| self.fg(theme.get().secondary.unwrap().color.unwrap()))
}
fn success(self) -> T {
self.green()
THEME.with(|theme| self.fg(theme.get().success.unwrap().color.unwrap()))
}
fn system_function(self) -> T {
self.yellow()
THEME.with(|theme| self.fg(theme.get().system_function.unwrap().color.unwrap()))
}
fn unmonitored(self) -> T {
self.gray()
THEME.with(|theme| self.fg(theme.get().unmonitored.unwrap().color.unwrap()))
}
fn unmonitored_missing(self) -> T {
self.yellow()
THEME.with(|theme| self.fg(theme.get().unmonitored_missing.unwrap().color.unwrap()))
}
fn unreleased(self) -> T {
self.light_cyan()
THEME.with(|theme| self.fg(theme.get().unreleased.unwrap().color.unwrap()))
}
fn warning(self) -> T {
self.magenta()
THEME.with(|theme| self.fg(theme.get().warning.unwrap().color.unwrap()))
}
}
+7 -4
View File
@@ -1,9 +1,9 @@
#[cfg(test)]
mod test {
use crate::ui::styles::{ManagarrStyle, COLOR_ORANGE};
use crate::ui::styles::ManagarrStyle;
use pretty_assertions::assert_eq;
use ratatui::prelude::Modifier;
use ratatui::style::{Style, Stylize};
use ratatui::style::{Color, Style, Stylize};
#[test]
fn test_new() {
@@ -14,13 +14,16 @@ mod test {
fn test_style_awaiting_import() {
assert_eq!(
Style::new().awaiting_import(),
Style::new().fg(COLOR_ORANGE)
Style::new().fg(Color::Rgb(255, 170, 66))
);
}
#[test]
fn test_style_indeterminate() {
assert_eq!(Style::new().indeterminate(), Style::new().fg(COLOR_ORANGE));
assert_eq!(
Style::new().indeterminate(),
Style::new().fg(Color::Rgb(255, 170, 66))
);
}
#[test]
+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::THEME;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Style, Stylize};
use ratatui::style::{Style, Stylize};
use ratatui::symbols;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, BorderType, Borders, LineGauge, ListItem, Paragraph, Wrap};
pub const COLOR_TEAL: Color = Color::Rgb(35, 50, 55);
#[cfg(test)]
#[path = "utils_tests.rs"]
mod utils_tests;
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> {
Block::new()
.default()
.borders(Borders::ALL)
.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> {
Block::new().borders(Borders::TOP)
Block::new().borders(Borders::TOP).default()
}
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<'_> {
@@ -47,7 +55,7 @@ pub fn layout_paragraph_borderless(string: &str) -> Paragraph<'_> {
}
pub fn borderless_block<'a>() -> Block<'a> {
Block::new()
Block::new().default()
}
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<'_> {
LineGauge::new()
.block(Block::new().title(title))
.filled_style(Style::new().cyan())
.filled_style(Style::new().primary())
.line_set(symbols::line::THICK)
.ratio(ratio)
.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<'_> {
LineGauge::new()
.block(Block::new())
.filled_style(Style::new().cyan())
.filled_style(Style::new().primary())
.line_set(symbols::line::THICK)
.ratio(ratio)
.label(Line::from(format!("{title}: {:.0}%", ratio * 100.0)))
+24 -21
View File
@@ -1,5 +1,6 @@
#[cfg(test)]
mod test {
use crate::ui::styles::ManagarrStyle;
use crate::ui::utils::{
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,
@@ -17,7 +18,8 @@ mod test {
fn test_layout_block() {
assert_eq!(
layout_block(),
Block::default()
Block::new()
.default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
);
@@ -27,11 +29,12 @@ mod test {
fn test_layout_block_with_title() {
let title_span = Span::styled(
"title",
Style::default()
Style::new()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
let expected_block = Block::default()
let expected_block = Block::new()
.default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(title_span.clone());
@@ -43,11 +46,12 @@ mod test {
fn test_layout_block_top_border_with_title() {
let title_span = Span::styled(
"title",
Style::default()
Style::new()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
let expected_block = Block::default()
let expected_block = Block::new()
.default()
.borders(Borders::TOP)
.title(title_span.clone());
@@ -61,7 +65,7 @@ mod test {
fn test_layout_block_top_border() {
assert_eq!(
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() {
assert_eq!(
layout_block_bottom_border(),
Block::default().borders(Borders::BOTTOM)
Block::new().borders(Borders::BOTTOM).default()
);
}
#[test]
fn test_borderless_block() {
assert_eq!(borderless_block(), Block::default());
assert_eq!(borderless_block(), Block::new().default());
}
#[test]
fn test_style_button_highlight_selected() {
let expected_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let expected_style = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
assert_eq!(style_block_highlight(true), expected_style);
}
#[test]
fn test_style_button_highlight_unselected() {
let expected_style = Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD);
let expected_style = Style::new().fg(Color::White).add_modifier(Modifier::BOLD);
assert_eq!(style_block_highlight(false), expected_style);
}
#[test]
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);
}
#[test]
fn test_title_block() {
let expected_block = Block::default()
let expected_block = Block::new()
.default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" test ",
Style::default().add_modifier(Modifier::BOLD),
Style::new().add_modifier(Modifier::BOLD),
));
assert_eq!(title_block("test"), expected_block);
@@ -118,12 +119,13 @@ mod test {
#[test]
fn test_title_block_centered() {
let expected_block = Block::default()
let expected_block = Block::new()
.default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" test ",
Style::default().add_modifier(Modifier::BOLD),
Style::new().add_modifier(Modifier::BOLD),
))
.title_alignment(Alignment::Center);
@@ -132,12 +134,13 @@ mod test {
#[test]
fn test_logo_block() {
let expected_block = Block::default()
let expected_block = Block::new()
.default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Managarr - A Servarr management TUI ",
Style::default()
Style::new()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::ITALIC),