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}