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}