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 glyph set, sibling of [`Theme`]. Components ask
23/// `theme::active().glyphs.pass` for the "Pass" glyph rather than
24/// hardcoding `"✓"` — that's what makes `--ascii` work without
25/// touching every screen.
26///
27/// Both variants are intentionally short (1–4 chars) so column
28/// alignment in tables doesn't break when an operator switches
29/// modes mid-session.
30#[derive(Debug, Clone, Copy)]
31pub struct Glyphs {
32    /// Pass / healthy / synced.
33    pub pass: &'static str,
34    /// Warn / cautionary / skewed.
35    pub warn: &'static str,
36    /// Fail / critical / expired.
37    pub fail: &'static str,
38    /// Pending — chain-confirmation gating, queued.
39    pub pending: &'static str,
40    /// In-progress / partial — the bee::warmup sub-step glyph.
41    pub in_progress: &'static str,
42    /// Filled bar segment in fill bars.
43    pub bar_filled: &'static str,
44    /// Empty bar segment in fill bars.
45    pub bar_empty: &'static str,
46    /// Selection cursor on selectable rows (S2 stamps, S6 peers).
47    pub cursor: &'static str,
48    /// Truncation suffix for short addresses (`abc…123`).
49    pub ellipsis: &'static str,
50    /// Tree-continuation glyph for tooltip lines under a row.
51    pub continuation: &'static str,
52    /// Bullet / separator in dense one-liners (`prod-1 · 12ms · …`).
53    pub bullet: &'static str,
54    /// Em dash placeholder for missing values (`—`).
55    pub em_dash: &'static str,
56}
57
58impl Glyphs {
59    /// The default Unicode set. Looks great in modern terminals
60    /// (kitty, WezTerm, iTerm2, recent gnome-terminal).
61    pub const fn unicode() -> Self {
62        Self {
63            pass: "✓",
64            warn: "⚠",
65            fail: "✗",
66            pending: "⏳",
67            in_progress: "▒",
68            bar_filled: "▇",
69            bar_empty: "░",
70            cursor: "▶",
71            ellipsis: "…",
72            continuation: "└─",
73            bullet: "·",
74            em_dash: "—",
75        }
76    }
77
78    /// ASCII-only fallback for terminals without good Unicode
79    /// support (Windows Terminal pre-Win11, some SSH chains, screen
80    /// readers). All entries stay 1–4 chars so column alignment is
81    /// stable.
82    pub const fn ascii() -> Self {
83        Self {
84            pass: "OK",
85            warn: "!",
86            fail: "X",
87            pending: "..",
88            in_progress: "##",
89            bar_filled: "#",
90            bar_empty: ".",
91            cursor: ">",
92            ellipsis: "...",
93            continuation: "+-",
94            bullet: "|",
95            em_dash: "--",
96        }
97    }
98}
99
100/// Slot-based colour palette. New slots get added as components are
101/// migrated; never break a slot's *meaning* between releases.
102#[derive(Debug, Clone, Copy)]
103pub struct Theme {
104    /// Bold / accent text — section titles, badges.
105    pub accent: Color,
106    /// Healthy / Pass status.
107    pub pass: Color,
108    /// Cautionary / Warn / InProgress status.
109    pub warn: Color,
110    /// Failure / error / Fail status.
111    pub fail: Color,
112    /// Informational / cyan (ping value, hashes).
113    pub info: Color,
114    /// Quiet / dim — labels, footnotes, "—" placeholders.
115    pub dim: Color,
116    /// Background highlight for the active tab strip.
117    pub tab_active_bg: Color,
118    /// Foreground for the active tab strip.
119    pub tab_active_fg: Color,
120    /// Active glyph set. Driven by `--ascii` / `[ui].ascii_fallback`.
121    pub glyphs: Glyphs,
122}
123
124impl Theme {
125    /// Vibrant default: green/yellow/red status, cyan accents,
126    /// Unicode glyphs.
127    pub const fn default_palette() -> Self {
128        Self {
129            accent: Color::Yellow,
130            pass: Color::Green,
131            warn: Color::Yellow,
132            fail: Color::Red,
133            info: Color::Cyan,
134            dim: Color::DarkGray,
135            tab_active_bg: Color::Yellow,
136            tab_active_fg: Color::Black,
137            glyphs: Glyphs::unicode(),
138        }
139    }
140
141    /// Monochrome variant for terminals where colour is muted or
142    /// distracting. Status is encoded by *intensity* + the existing
143    /// glyphs (`✓ ⚠ ✗`) instead of hue.
144    pub const fn mono() -> Self {
145        Self {
146            accent: Color::White,
147            pass: Color::White,
148            warn: Color::Gray,
149            fail: Color::White,
150            info: Color::Gray,
151            dim: Color::DarkGray,
152            tab_active_bg: Color::White,
153            tab_active_fg: Color::Black,
154            glyphs: Glyphs::unicode(),
155        }
156    }
157
158    /// Override the glyph set in-place. Used by [`install_with_overrides`]
159    /// when `--ascii` / `NO_COLOR` resolution decides Unicode is not
160    /// safe.
161    pub const fn with_glyphs(mut self, glyphs: Glyphs) -> Self {
162        self.glyphs = glyphs;
163        self
164    }
165}
166
167impl Default for Theme {
168    fn default() -> Self {
169        Self::default_palette()
170    }
171}
172
173static ACTIVE: OnceLock<Theme> = OnceLock::new();
174
175/// Resolve a theme name to a [`Theme`]. Unknown names fall back to
176/// the default palette with a single tracing warning so the operator
177/// notices the typo without the binary refusing to start.
178pub fn from_name(name: &str) -> Theme {
179    match name {
180        "default" => Theme::default_palette(),
181        "mono" => Theme::mono(),
182        other => {
183            tracing::warn!(theme = %other, "unknown theme name; falling back to default");
184            Theme::default_palette()
185        }
186    }
187}
188
189/// Set the active theme from the `[ui]` config section. Called once
190/// during [`crate::app::App::new`]. Re-calling silently no-ops; full
191/// runtime theme switching is a v0.6 feature.
192pub fn install(ui: &UiConfig) {
193    let _ = ACTIVE.set(from_name(&ui.theme));
194}
195
196/// Resolution rules for `--ascii` / `--no-color` / `NO_COLOR`,
197/// applied on top of the `[ui]` config section. CLI flags win over
198/// env, env wins over config — the operator who typed `--ascii`
199/// gets ASCII, and a `NO_COLOR=1` runtime override on a coloured
200/// `[ui].theme = "default"` config still demotes to mono.
201///
202/// `force_no_color` should be `true` if either the `--no-color`
203/// flag is set OR a non-empty `NO_COLOR` environment variable is
204/// present (per the [no-color.org](https://no-color.org) spec).
205/// `force_ascii` should be `true` for `--ascii`; the
206/// `[ui].ascii_fallback` boolean from config is OR'd in here too.
207///
208/// Re-calling silently no-ops, same as [`install`].
209pub fn install_with_overrides(ui: &UiConfig, force_no_color: bool, force_ascii: bool) {
210    let palette_name = if force_no_color { "mono" } else { ui.theme.as_str() };
211    let ascii = force_ascii || ui.ascii_fallback;
212    let glyphs = if ascii { Glyphs::ascii() } else { Glyphs::unicode() };
213    let theme = from_name(palette_name).with_glyphs(glyphs);
214    let _ = ACTIVE.set(theme);
215}
216
217/// Detect whether the host terminal asked us to suppress colour.
218/// Honours the [no-color.org](https://no-color.org) convention —
219/// any non-empty `NO_COLOR` env var means "no colour".
220pub fn no_color_env() -> bool {
221    std::env::var("NO_COLOR")
222        .map(|v| !v.is_empty())
223        .unwrap_or(false)
224}
225
226/// Read the active theme. Returns the default palette if [`install`]
227/// was never called (e.g. in unit tests that don't go through
228/// `App::new`).
229pub fn active() -> &'static Theme {
230    static FALLBACK: Theme = Theme::default_palette();
231    ACTIVE.get().unwrap_or(&FALLBACK)
232}
233
234/// Classify a header-line error message into a render colour + a
235/// friendlier prefix. Bee returns
236/// `HTTP 503: Node is syncing. This endpoint is unavailable. Try
237/// again later.` for almost every endpoint during the first few
238/// minutes after startup; rendering that in red as a hard error
239/// gives a terrible first impression. We detect the syncing case
240/// and render it in `warn` colour with a one-sentence explanation.
241///
242/// Returned tuple: `(colour, formatted message ready to render)`.
243pub fn classify_header_error(err: &str) -> (Color, String) {
244    if err.to_lowercase().contains("syncing") {
245        (
246            active().warn,
247            "syncing — Bee is still bootstrapping; this view will populate once it catches up"
248                .into(),
249        )
250    } else {
251        (active().fail, format!("error: {err}"))
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn from_name_recognises_known() {
261        assert_eq!(
262            std::mem::discriminant(&from_name("default").pass),
263            std::mem::discriminant(&Color::Green)
264        );
265        assert_eq!(from_name("mono").pass, Color::White);
266    }
267
268    #[test]
269    fn from_name_falls_back_on_unknown() {
270        // Same shape as default — no panic, just a warn log.
271        let t = from_name("not-a-real-theme");
272        assert_eq!(t.pass, Theme::default_palette().pass);
273    }
274
275    #[test]
276    fn active_returns_fallback_when_not_installed() {
277        // OnceLock is process-global — under cargo test it may be
278        // installed by another test that ran first. Either branch is
279        // valid; just check we get *some* theme without panicking.
280        let _ = active();
281    }
282
283    #[test]
284    fn glyph_sets_are_distinct_and_short() {
285        let u = Glyphs::unicode();
286        let a = Glyphs::ascii();
287        assert_ne!(u.pass, a.pass);
288        assert_ne!(u.fail, a.fail);
289        // Every ascii glyph stays at most 4 chars so column widths
290        // don't drift between modes.
291        for g in [
292            a.pass, a.warn, a.fail, a.pending, a.in_progress,
293            a.bar_filled, a.bar_empty, a.cursor, a.ellipsis,
294            a.continuation, a.bullet, a.em_dash,
295        ] {
296            assert!(g.len() <= 4, "ascii glyph too wide: {g:?}");
297        }
298    }
299
300    #[test]
301    fn theme_with_glyphs_swaps_glyphs_only() {
302        let t = Theme::default_palette().with_glyphs(Glyphs::ascii());
303        assert_eq!(t.pass, Color::Green); // palette unchanged
304        assert_eq!(t.glyphs.pass, "OK"); // glyphs swapped
305    }
306}