use std::sync::OnceLock;
use ratatui::style::Color;
use crate::config::UiConfig;
#[derive(Debug, Clone, Copy)]
pub struct Glyphs {
pub pass: &'static str,
pub warn: &'static str,
pub fail: &'static str,
pub pending: &'static str,
pub in_progress: &'static str,
pub bar_filled: &'static str,
pub bar_empty: &'static str,
pub cursor: &'static str,
pub ellipsis: &'static str,
pub continuation: &'static str,
pub bullet: &'static str,
pub em_dash: &'static str,
}
impl Glyphs {
pub const fn unicode() -> Self {
Self {
pass: "✓",
warn: "⚠",
fail: "✗",
pending: "⏳",
in_progress: "▒",
bar_filled: "▇",
bar_empty: "░",
cursor: "▶",
ellipsis: "…",
continuation: "└─",
bullet: "·",
em_dash: "—",
}
}
pub const fn ascii() -> Self {
Self {
pass: "OK",
warn: "!",
fail: "X",
pending: "..",
in_progress: "##",
bar_filled: "#",
bar_empty: ".",
cursor: ">",
ellipsis: "...",
continuation: "+-",
bullet: "|",
em_dash: "--",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Theme {
pub accent: Color,
pub pass: Color,
pub warn: Color,
pub fail: Color,
pub info: Color,
pub dim: Color,
pub tab_active_bg: Color,
pub tab_active_fg: Color,
pub glyphs: Glyphs,
}
impl Theme {
pub const fn default_palette() -> Self {
Self {
accent: Color::Yellow,
pass: Color::Green,
warn: Color::Yellow,
fail: Color::Red,
info: Color::Cyan,
dim: Color::DarkGray,
tab_active_bg: Color::Yellow,
tab_active_fg: Color::Black,
glyphs: Glyphs::unicode(),
}
}
pub const fn mono() -> Self {
Self {
accent: Color::White,
pass: Color::White,
warn: Color::Gray,
fail: Color::White,
info: Color::Gray,
dim: Color::DarkGray,
tab_active_bg: Color::White,
tab_active_fg: Color::Black,
glyphs: Glyphs::unicode(),
}
}
pub const fn with_glyphs(mut self, glyphs: Glyphs) -> Self {
self.glyphs = glyphs;
self
}
}
impl Default for Theme {
fn default() -> Self {
Self::default_palette()
}
}
static ACTIVE: OnceLock<Theme> = OnceLock::new();
pub fn from_name(name: &str) -> Theme {
match name {
"default" => Theme::default_palette(),
"mono" => Theme::mono(),
other => {
tracing::warn!(theme = %other, "unknown theme name; falling back to default");
Theme::default_palette()
}
}
}
pub fn install(ui: &UiConfig) {
let _ = ACTIVE.set(from_name(&ui.theme));
}
pub fn install_with_overrides(ui: &UiConfig, force_no_color: bool, force_ascii: bool) {
let palette_name = if force_no_color { "mono" } else { ui.theme.as_str() };
let ascii = force_ascii || ui.ascii_fallback;
let glyphs = if ascii { Glyphs::ascii() } else { Glyphs::unicode() };
let theme = from_name(palette_name).with_glyphs(glyphs);
let _ = ACTIVE.set(theme);
}
pub fn no_color_env() -> bool {
std::env::var("NO_COLOR")
.map(|v| !v.is_empty())
.unwrap_or(false)
}
pub fn active() -> &'static Theme {
static FALLBACK: Theme = Theme::default_palette();
ACTIVE.get().unwrap_or(&FALLBACK)
}
pub fn classify_header_error(err: &str) -> (Color, String) {
if err.to_lowercase().contains("syncing") {
(
active().warn,
"syncing — Bee is still bootstrapping; this view will populate once it catches up"
.into(),
)
} else {
(active().fail, format!("error: {err}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_name_recognises_known() {
assert_eq!(
std::mem::discriminant(&from_name("default").pass),
std::mem::discriminant(&Color::Green)
);
assert_eq!(from_name("mono").pass, Color::White);
}
#[test]
fn from_name_falls_back_on_unknown() {
let t = from_name("not-a-real-theme");
assert_eq!(t.pass, Theme::default_palette().pass);
}
#[test]
fn active_returns_fallback_when_not_installed() {
let _ = active();
}
#[test]
fn glyph_sets_are_distinct_and_short() {
let u = Glyphs::unicode();
let a = Glyphs::ascii();
assert_ne!(u.pass, a.pass);
assert_ne!(u.fail, a.fail);
for g in [
a.pass, a.warn, a.fail, a.pending, a.in_progress,
a.bar_filled, a.bar_empty, a.cursor, a.ellipsis,
a.continuation, a.bullet, a.em_dash,
] {
assert!(g.len() <= 4, "ascii glyph too wide: {g:?}");
}
}
#[test]
fn theme_with_glyphs_swaps_glyphs_only() {
let t = Theme::default_palette().with_glyphs(Glyphs::ascii());
assert_eq!(t.pass, Color::Green); assert_eq!(t.glyphs.pass, "OK"); }
}