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}