use std::sync::OnceLock;
use ratatui::style::Color;
use crate::config::UiConfig;
const SPINNER_FRAMES_UNICODE: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const SPINNER_FRAMES_ASCII: &[&str] = &["|", "/", "-", "\\"];
static SPINNER_TICK: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
pub fn advance_spinner() {
SPINNER_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
pub fn spinner_glyph() -> &'static str {
let frames = if active().glyphs.pass == Glyphs::unicode().pass {
SPINNER_FRAMES_UNICODE
} else {
SPINNER_FRAMES_ASCII
};
let i = SPINNER_TICK.load(std::sync::atomic::Ordering::Relaxed) % frames.len();
frames[i]
}
#[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"); }
#[test]
fn spinner_advances_through_frames() {
let initial = spinner_glyph();
let mut saw_different = false;
for _ in 0..20 {
advance_spinner();
if spinner_glyph() != initial {
saw_different = true;
break;
}
}
assert!(
saw_different,
"spinner_glyph should change as advance_spinner is called"
);
}
}