Skip to main content

bee_tui/
theme.rs

1//! Centralized colour palette. Set once at startup from
2//! [`crate::config::UiConfig::theme`]; read by screens via
3//! [`active`].
4//!
5//! Themes are deliberately *slot-based*: each slot is a semantic
6//! intent (Pass / Warn / Fail / Header / etc.), not a literal colour.
7//! Components ask the active theme "what colour for Warn?" rather
8//! than hard-coding `Color::Yellow` — that's what makes a `mono`
9//! variant possible without rewriting every component.
10//!
11//! The migration is incremental: components flip from hard-coded
12//! [`Color`] literals to [`active().pass`] etc. as their commits
13//! land. The first migrated component (S1 Health) ships in the same
14//! commit as the theme module.
15
16use std::sync::OnceLock;
17
18use ratatui::style::Color;
19
20use crate::config::UiConfig;
21
22/// Slot-based colour palette. New slots get added as components are
23/// migrated; never break a slot's *meaning* between releases.
24#[derive(Debug, Clone, Copy)]
25pub struct Theme {
26    /// Bold / accent text — section titles, badges.
27    pub accent: Color,
28    /// Healthy / Pass status.
29    pub pass: Color,
30    /// Cautionary / Warn / InProgress status.
31    pub warn: Color,
32    /// Failure / error / Fail status.
33    pub fail: Color,
34    /// Informational / cyan (ping value, hashes).
35    pub info: Color,
36    /// Quiet / dim — labels, footnotes, "—" placeholders.
37    pub dim: Color,
38    /// Background highlight for the active tab strip.
39    pub tab_active_bg: Color,
40    /// Foreground for the active tab strip.
41    pub tab_active_fg: Color,
42}
43
44impl Theme {
45    /// Vibrant default: green/yellow/red status, cyan accents.
46    pub const fn default_palette() -> Self {
47        Self {
48            accent: Color::Yellow,
49            pass: Color::Green,
50            warn: Color::Yellow,
51            fail: Color::Red,
52            info: Color::Cyan,
53            dim: Color::DarkGray,
54            tab_active_bg: Color::Yellow,
55            tab_active_fg: Color::Black,
56        }
57    }
58
59    /// Monochrome variant for terminals where colour is muted or
60    /// distracting. Status is encoded by *intensity* + the existing
61    /// glyphs (`✓ ⚠ ✗`) instead of hue.
62    pub const fn mono() -> Self {
63        Self {
64            accent: Color::White,
65            pass: Color::White,
66            warn: Color::Gray,
67            fail: Color::White,
68            info: Color::Gray,
69            dim: Color::DarkGray,
70            tab_active_bg: Color::White,
71            tab_active_fg: Color::Black,
72        }
73    }
74}
75
76impl Default for Theme {
77    fn default() -> Self {
78        Self::default_palette()
79    }
80}
81
82static ACTIVE: OnceLock<Theme> = OnceLock::new();
83
84/// Resolve a theme name to a [`Theme`]. Unknown names fall back to
85/// the default palette with a single tracing warning so the operator
86/// notices the typo without the binary refusing to start.
87pub fn from_name(name: &str) -> Theme {
88    match name {
89        "default" => Theme::default_palette(),
90        "mono" => Theme::mono(),
91        other => {
92            tracing::warn!(theme = %other, "unknown theme name; falling back to default");
93            Theme::default_palette()
94        }
95    }
96}
97
98/// Set the active theme from the `[ui]` config section. Called once
99/// during [`crate::app::App::new`]. Re-calling silently no-ops; full
100/// runtime theme switching is a v0.6 feature.
101pub fn install(ui: &UiConfig) {
102    let _ = ACTIVE.set(from_name(&ui.theme));
103}
104
105/// Read the active theme. Returns the default palette if [`install`]
106/// was never called (e.g. in unit tests that don't go through
107/// `App::new`).
108pub fn active() -> &'static Theme {
109    static FALLBACK: Theme = Theme::default_palette();
110    ACTIVE.get().unwrap_or(&FALLBACK)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn from_name_recognises_known() {
119        assert_eq!(
120            std::mem::discriminant(&from_name("default").pass),
121            std::mem::discriminant(&Color::Green)
122        );
123        assert_eq!(from_name("mono").pass, Color::White);
124    }
125
126    #[test]
127    fn from_name_falls_back_on_unknown() {
128        // Same shape as default — no panic, just a warn log.
129        let t = from_name("not-a-real-theme");
130        assert_eq!(t.pass, Theme::default_palette().pass);
131    }
132
133    #[test]
134    fn active_returns_fallback_when_not_installed() {
135        // OnceLock is process-global — under cargo test it may be
136        // installed by another test that ran first. Either branch is
137        // valid; just check we get *some* theme without panicking.
138        let _ = active();
139    }
140}