use std::fmt;
use std::str::FromStr;
use crate::cli::ColourPolicy;
use crate::output_mode::{OutputMode, no_color_active_with};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ThemePreference {
#[default]
Auto,
Unicode,
Ascii,
}
impl ThemePreference {
pub const VALID_OPTIONS: &'static [&'static str] = &["auto", "unicode", "ascii"];
pub fn parse_raw(s: &str) -> Result<Self, &'static [&'static str]> {
let trimmed = s.trim().to_ascii_lowercase();
match trimmed.as_str() {
"auto" => Ok(Self::Auto),
"unicode" => Ok(Self::Unicode),
"ascii" => Ok(Self::Ascii),
_ => Err(Self::VALID_OPTIONS),
}
}
}
impl fmt::Display for ThemePreference {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Auto => write!(f, "auto"),
Self::Unicode => write!(f, "unicode"),
Self::Ascii => write!(f, "ascii"),
}
}
}
impl FromStr for ThemePreference {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse_raw(s)
.map_err(|valid| format!("invalid theme '{s}'. Valid options: {}", valid.join(", ")))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ColourTokens {
pub error: SemanticColour,
pub warning: SemanticColour,
pub success: SemanticColour,
pub info: SemanticColour,
pub timing: SemanticColour,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SemanticColour {
Error,
Warning,
Success,
Info,
Timing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SymbolTokens {
pub error: &'static str,
pub warning: &'static str,
pub success: &'static str,
pub info: &'static str,
pub timing: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpacingTokens {
pub task_indent: &'static str,
pub timing_indent: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DesignTokens {
pub colours: ColourTokens,
pub symbols: SymbolTokens,
pub spacing: SpacingTokens,
pub emoji_allowed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedTheme {
pub tokens: DesignTokens,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ThemeContext {
pub no_emoji: Option<bool>,
pub colour_policy: Option<ColourPolicy>,
pub mode: OutputMode,
}
impl ThemeContext {
#[must_use]
pub const fn new(
no_emoji: Option<bool>,
colour_policy: Option<ColourPolicy>,
mode: OutputMode,
) -> Self {
Self {
no_emoji,
colour_policy,
mode,
}
}
}
const UNICODE_SYMBOLS: SymbolTokens = SymbolTokens {
error: "✖",
warning: "⚠",
success: "✔",
info: "ℹ",
timing: "⏱",
};
const ASCII_SYMBOLS: SymbolTokens = SymbolTokens {
error: "X",
warning: "!",
success: "+",
info: "i",
timing: "T",
};
const SPACING: SpacingTokens = SpacingTokens {
task_indent: " ",
timing_indent: " ",
};
const COLOURS: ColourTokens = ColourTokens {
error: SemanticColour::Error,
warning: SemanticColour::Warning,
success: SemanticColour::Success,
info: SemanticColour::Info,
timing: SemanticColour::Timing,
};
#[derive(Debug, Clone, Copy)]
struct EnvSignals {
no_color: bool,
}
const fn should_use_unicode(
theme: Option<ThemePreference>,
no_emoji: Option<bool>,
env: EnvSignals,
mode: OutputMode,
) -> bool {
match theme {
Some(ThemePreference::Unicode) => true,
Some(ThemePreference::Ascii) => false,
Some(ThemePreference::Auto) | None => {
if let Some(true) = no_emoji {
return false;
}
if env.no_color {
return false;
}
!mode.is_accessible()
}
}
}
#[must_use]
pub fn resolve_theme<F>(
theme: Option<ThemePreference>,
context: ThemeContext,
read_env: F,
) -> ResolvedTheme
where
F: Fn(&str) -> Option<String>,
{
let env = EnvSignals {
no_color: no_color_active_with(context.colour_policy, &read_env),
};
let use_unicode = should_use_unicode(theme, context.no_emoji, env, context.mode);
let symbols = if use_unicode {
UNICODE_SYMBOLS
} else {
ASCII_SYMBOLS
};
ResolvedTheme {
tokens: DesignTokens {
colours: COLOURS,
symbols,
spacing: SPACING,
emoji_allowed: use_unicode,
},
}
}
#[cfg(test)]
#[expect(
clippy::too_many_arguments,
reason = "rstest parameterized tests need multiple parameters"
)]
#[path = "theme_tests.rs"]
mod tests;