use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::Theme;
pub trait ThemeExt: Theme {
fn fg_accent<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.accent())
}
fn fg_dim<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.text_dim())
}
fn fg_bright<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.text_bright())
}
fn fg_text<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.text())
}
fn fg_success<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.success())
}
fn fg_error<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.error())
}
fn fg_warning<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.warning())
}
fn fg_info<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.info())
}
fn fg_added<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.diff_added())
}
fn fg_removed<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.diff_removed())
}
fn fg_border<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.border())
}
fn bar(&self, percent: u8) -> ThemedBar {
ThemedBar {
percent: percent.min(100),
width: 12,
filled: self.accent(),
empty: self.border(),
}
}
fn separator_line(&self, width: u16) -> Line<'static> {
Line::styled(
" · ".repeat((width / 3) as usize),
Style::default().fg(self.border()),
)
}
fn badge<'a>(&self, text: impl Into<std::borrow::Cow<'a, str>>, bg: Color) -> ThemedSpan<'a> {
ThemedSpan::new(text, self.surface()).on(bg).bold()
}
fn style_accent(&self) -> Style {
Style::default().fg(self.accent())
}
fn style_border(&self) -> Style {
Style::default().fg(self.border())
}
fn style_error(&self) -> Style {
Style::default().fg(self.error())
}
fn style_warning(&self) -> Style {
Style::default().fg(self.warning())
}
fn style_success(&self) -> Style {
Style::default().fg(self.success())
}
fn style_bright(&self) -> Style {
Style::default().fg(self.text_bright())
}
fn style_dim(&self) -> Style {
Style::default().fg(self.text_dim())
}
}
#[must_use]
pub fn style_fg(color: Color) -> Style {
Style::default().fg(color)
}
impl<T: Theme + ?Sized> ThemeExt for T {}
pub struct ThemedSpan<'a> {
text: std::borrow::Cow<'a, str>,
fg: Color,
bg: Option<Color>,
modifiers: Modifier,
}
impl<'a> ThemedSpan<'a> {
#[must_use]
pub fn with_color(text: impl Into<std::borrow::Cow<'a, str>>, fg: Color) -> Self {
Self {
text: text.into(),
fg,
bg: None,
modifiers: Modifier::empty(),
}
}
pub(crate) fn new(text: impl Into<std::borrow::Cow<'a, str>>, fg: Color) -> Self {
Self::with_color(text, fg)
}
#[must_use]
pub fn bold(mut self) -> Self {
self.modifiers |= Modifier::BOLD;
self
}
#[must_use]
pub fn italic(mut self) -> Self {
self.modifiers |= Modifier::ITALIC;
self
}
#[must_use]
pub fn dimmed(mut self) -> Self {
self.modifiers |= Modifier::DIM;
self
}
#[must_use]
pub fn on(mut self, bg: Color) -> Self {
self.bg = Some(bg);
self
}
#[must_use]
pub fn build(self) -> Span<'a> {
let mut style = Style::default().fg(self.fg);
if let Some(bg) = self.bg {
style = style.bg(bg);
}
if !self.modifiers.is_empty() {
style = style.add_modifier(self.modifiers);
}
Span::styled(self.text, style)
}
}
impl<'a> From<ThemedSpan<'a>> for Span<'a> {
fn from(ts: ThemedSpan<'a>) -> Self {
ts.build()
}
}
pub struct ThemedBar {
percent: u8,
width: u16,
filled: Color,
empty: Color,
}
impl ThemedBar {
#[must_use]
pub fn width(self, w: u16) -> Self {
Self { width: w, ..self }
}
#[must_use]
pub fn build(&self) -> Line<'static> {
let pct = self.percent as usize;
let w = self.width as usize;
let filled = w * pct / 100;
let empty = w - filled;
Line::from(vec![
Span::styled("▰".repeat(filled), Style::default().fg(self.filled)),
Span::styled("▱".repeat(empty), Style::default().fg(self.empty)),
Span::styled(
format!(" {pct}%"),
Style::default()
.fg(self.filled)
.add_modifier(Modifier::BOLD),
),
])
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::{CatppuccinMocha, Dracula, NoColor, Theme};
#[test]
fn fg_accent_uses_theme_accent() {
let t = CatppuccinMocha;
let span = t.fg_accent("hello").build();
assert_eq!(span.style.fg, Some(t.accent()));
}
#[test]
fn fg_dim_uses_theme_text_dim() {
let t = CatppuccinMocha;
let span = t.fg_dim("muted").build();
assert_eq!(span.style.fg, Some(t.text_dim()));
}
#[test]
fn fg_bright_uses_theme_text_bright() {
let t = CatppuccinMocha;
let span = t.fg_bright("bold").build();
assert_eq!(span.style.fg, Some(t.text_bright()));
}
#[test]
fn fg_text_uses_theme_text() {
let t = CatppuccinMocha;
let span = t.fg_text("normal").build();
assert_eq!(span.style.fg, Some(t.text()));
}
#[test]
fn fg_success_uses_theme_success() {
let t = CatppuccinMocha;
let span = t.fg_success("ok").build();
assert_eq!(span.style.fg, Some(t.success()));
}
#[test]
fn fg_error_uses_theme_error() {
let t = CatppuccinMocha;
let span = t.fg_error("fail").build();
assert_eq!(span.style.fg, Some(t.error()));
}
#[test]
fn fg_warning_uses_theme_warning() {
let t = CatppuccinMocha;
let span = t.fg_warning("warn").build();
assert_eq!(span.style.fg, Some(t.warning()));
}
#[test]
fn fg_info_uses_theme_info() {
let t = CatppuccinMocha;
let span = t.fg_info("info").build();
assert_eq!(span.style.fg, Some(t.info()));
}
#[test]
fn fg_added_uses_theme_diff_added() {
let t = CatppuccinMocha;
let span = t.fg_added("+line").build();
assert_eq!(span.style.fg, Some(t.diff_added()));
}
#[test]
fn fg_removed_uses_theme_diff_removed() {
let t = CatppuccinMocha;
let span = t.fg_removed("-line").build();
assert_eq!(span.style.fg, Some(t.diff_removed()));
}
#[test]
fn fg_border_uses_theme_border() {
let t = CatppuccinMocha;
let span = t.fg_border("---").build();
assert_eq!(span.style.fg, Some(t.border()));
}
#[test]
fn bold_adds_bold_modifier() {
let t = CatppuccinMocha;
let span = t.fg_accent("title").bold().build();
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn italic_adds_italic_modifier() {
let t = CatppuccinMocha;
let span = t.fg_dim("hint").italic().build();
assert!(span.style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn dimmed_adds_dim_modifier() {
let t = CatppuccinMocha;
let span = t.fg_text("faded").dimmed().build();
assert!(span.style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn chained_modifiers_combine() {
let t = CatppuccinMocha;
let span = t.fg_accent("both").bold().italic().build();
assert!(span.style.add_modifier.contains(Modifier::BOLD));
assert!(span.style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn on_sets_background() {
let t = CatppuccinMocha;
let span = t.fg_accent("badge").on(Color::Red).build();
assert_eq!(span.style.bg, Some(Color::Red));
}
#[test]
fn themed_span_converts_to_span() {
let t = CatppuccinMocha;
let themed = t.fg_success("ok").bold();
let span: Span<'_> = themed.into();
assert_eq!(span.style.fg, Some(t.success()));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn badge_has_background_and_bold() {
let t = CatppuccinMocha;
let span = t.badge(" RUNNING ", Color::Green).build();
assert_eq!(span.style.bg, Some(Color::Green));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn bar_at_zero_is_all_empty() {
let t = CatppuccinMocha;
let line = t.bar(0).build();
let text: String = line.spans.iter().map(|s| s.content.to_string()).collect();
assert!(text.starts_with("▱"));
assert!(text.contains("0%"));
}
#[test]
fn bar_at_100_is_all_filled() {
let t = CatppuccinMocha;
let line = t.bar(100).build();
let text: String = line.spans.iter().map(|s| s.content.to_string()).collect();
assert!(text.starts_with("▰"));
assert!(text.contains("100%"));
}
#[test]
fn bar_at_50_is_half() {
let t = CatppuccinMocha;
let line = t.bar(50).width(10).build();
let filled: String = line.spans[0].content.to_string();
let empty: String = line.spans[1].content.to_string();
assert_eq!(filled.chars().count(), 5);
assert_eq!(empty.chars().count(), 5);
}
#[test]
fn bar_clamps_above_100() {
let t = CatppuccinMocha;
let line = t.bar(200).build();
let text: String = line.spans.iter().map(|s| s.content.to_string()).collect();
assert!(text.contains("100%"));
}
#[test]
fn bar_width_changes_length() {
let t = CatppuccinMocha;
let line = t.bar(50).width(20).build();
let filled: String = line.spans[0].content.to_string();
assert_eq!(filled.chars().count(), 10);
}
#[test]
fn separator_line_produces_content() {
let t = CatppuccinMocha;
let line = t.separator_line(30);
let text: String = line.spans.iter().map(|s| s.content.to_string()).collect();
assert!(text.contains("·"), "separator should contain dots");
}
#[test]
fn builders_work_on_dyn_theme() {
let t: &dyn Theme = &Dracula;
let span = t.fg_accent("test").bold().build();
assert_eq!(span.style.fg, Some(t.accent()));
}
#[test]
fn no_color_builders_produce_reset() {
let t = NoColor;
let span = t.fg_accent("text").build();
assert_eq!(span.style.fg, Some(Color::Reset));
}
#[test]
fn with_color_creates_span_with_dynamic_color() {
let span = ThemedSpan::with_color("dynamic", Color::Rgb(100, 200, 50))
.bold()
.build();
assert_eq!(span.style.fg, Some(Color::Rgb(100, 200, 50)));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn owned_string_accepted() {
let t = CatppuccinMocha;
let text = format!("dynamic {}", 42);
let span = t.fg_accent(text).build();
assert!(span.content.contains("42"));
}
#[test]
fn empty_string_produces_empty_span() {
let t = CatppuccinMocha;
let span = t.fg_accent("").build();
assert!(span.content.is_empty());
assert_eq!(span.style.fg, Some(t.accent()));
}
#[test]
fn bar_zero_width_does_not_panic() {
let t = CatppuccinMocha;
let line = t.bar(50).width(0).build();
assert!(!line.spans.is_empty());
}
#[test]
fn separator_zero_width_does_not_panic() {
let t = CatppuccinMocha;
let line = t.separator_line(0);
assert!(line.spans.len() <= 1);
}
#[test]
fn with_color_reset_produces_reset() {
let span = ThemedSpan::with_color("text", Color::Reset).build();
assert_eq!(span.style.fg, Some(Color::Reset));
}
#[test]
fn style_helpers_produce_correct_colors() {
let t = CatppuccinMocha;
assert_eq!(t.style_accent().fg, Some(t.accent()));
assert_eq!(t.style_border().fg, Some(t.border()));
assert_eq!(t.style_error().fg, Some(t.error()));
assert_eq!(t.style_warning().fg, Some(t.warning()));
assert_eq!(t.style_success().fg, Some(t.success()));
assert_eq!(t.style_bright().fg, Some(t.text_bright()));
assert_eq!(t.style_dim().fg, Some(t.text_dim()));
}
#[test]
fn style_fg_with_dynamic_color() {
let s = super::style_fg(Color::Rgb(1, 2, 3));
assert_eq!(s.fg, Some(Color::Rgb(1, 2, 3)));
}
}