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