Skip to main content

rustyclaw_core/
theme.rs

1//! Terminal theme & spinner helpers.
2//!
3//! Mirrors openclaw's "lobster palette" (`src/terminal/palette.ts`) and
4//! `src/terminal/theme.ts`.  Respects the `NO_COLOR` env-var and the
5//! `--no-color` CLI flag.
6//!
7//! # Palette (from openclaw docs/cli/index.md)
8//!
9//! | Token          | Hex       | Usage                          |
10//! |----------------|-----------|--------------------------------|
11//! | accent         | `#FF5A2D` | headings, labels, primary      |
12//! | accent_bright  | `#FF7A3D` | command names, emphasis         |
13//! | accent_dim     | `#D14A22` | secondary highlight             |
14//! | info           | `#FF8A5B` | informational values            |
15//! | success        | `#2FBF71` | success states                  |
16//! | warn           | `#FFB020` | warnings, fallbacks             |
17//! | error          | `#E23D2D` | errors, failures                |
18//! | muted          | `#8B7F77` | de-emphasis, metadata           |
19
20use colored::Colorize;
21use indicatif::{ProgressBar, ProgressStyle};
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::time::Duration;
24
25// ── Global color toggle ─────────────────────────────────────────────────────
26
27static COLOR_DISABLED: AtomicBool = AtomicBool::new(false);
28
29/// Call once at startup (after CLI parsing) to disable colour globally.
30pub fn disable_color() {
31    COLOR_DISABLED.store(true, Ordering::Relaxed);
32    colored::control::set_override(false);
33}
34
35/// Initialise the colour system.  Checks `NO_COLOR` env-var and optional
36/// `--no-color` flag.
37pub fn init_color(no_color_flag: bool) {
38    if no_color_flag
39        || std::env::var("NO_COLOR")
40            .map(|v| !v.is_empty())
41            .unwrap_or(false)
42    {
43        disable_color();
44    }
45}
46
47fn is_color() -> bool {
48    !COLOR_DISABLED.load(Ordering::Relaxed)
49}
50
51// ── Lobster palette ─────────────────────────────────────────────────────────
52
53/// Lobster palette hex values — source of truth.
54pub mod palette {
55    pub const ACCENT: (u8, u8, u8) = (0xFF, 0x5A, 0x2D);
56    pub const ACCENT_BRIGHT: (u8, u8, u8) = (0xFF, 0x7A, 0x3D);
57    pub const ACCENT_DIM: (u8, u8, u8) = (0xD1, 0x4A, 0x22);
58    pub const INFO: (u8, u8, u8) = (0xFF, 0x8A, 0x5B);
59    pub const SUCCESS: (u8, u8, u8) = (0x2F, 0xBF, 0x71);
60    pub const WARN: (u8, u8, u8) = (0xFF, 0xB0, 0x20);
61    pub const ERROR: (u8, u8, u8) = (0xE2, 0x3D, 0x2D);
62    pub const MUTED: (u8, u8, u8) = (0x8B, 0x7F, 0x77);
63}
64
65// ── Themed formatting helpers ───────────────────────────────────────────────
66//
67// Each function returns a `String` so callers can `println!("{}", accent("…"))`.
68
69fn apply(text: &str, rgb: (u8, u8, u8)) -> String {
70    if is_color() {
71        text.truecolor(rgb.0, rgb.1, rgb.2).to_string()
72    } else {
73        text.to_string()
74    }
75}
76
77fn apply_bold(text: &str, rgb: (u8, u8, u8)) -> String {
78    if is_color() {
79        text.truecolor(rgb.0, rgb.1, rgb.2).bold().to_string()
80    } else {
81        text.to_string()
82    }
83}
84
85/// Primary accent (headings, labels).
86pub fn accent(text: &str) -> String {
87    apply(text, palette::ACCENT)
88}
89
90/// Bright accent (command names, emphasis).
91pub fn accent_bright(text: &str) -> String {
92    apply(text, palette::ACCENT_BRIGHT)
93}
94
95/// Dim accent (secondary highlight).
96pub fn accent_dim(text: &str) -> String {
97    apply(text, palette::ACCENT_DIM)
98}
99
100/// Informational values.
101pub fn info(text: &str) -> String {
102    apply(text, palette::INFO)
103}
104
105/// Success state.
106pub fn success(text: &str) -> String {
107    apply(text, palette::SUCCESS)
108}
109
110/// Warning / attention.
111pub fn warn(text: &str) -> String {
112    apply(text, palette::WARN)
113}
114
115/// Error / failure.
116pub fn error(text: &str) -> String {
117    apply(text, palette::ERROR)
118}
119
120/// De-emphasis / metadata.
121pub fn muted(text: &str) -> String {
122    apply(text, palette::MUTED)
123}
124
125/// Bold heading in accent colour.
126pub fn heading(text: &str) -> String {
127    apply_bold(text, palette::ACCENT)
128}
129
130/// Bold text (no colour).
131pub fn bold(text: &str) -> String {
132    if is_color() {
133        text.bold().to_string()
134    } else {
135        text.to_string()
136    }
137}
138
139/// Dimmed text (terminal dim attribute).
140pub fn dim(text: &str) -> String {
141    if is_color() {
142        text.dimmed().to_string()
143    } else {
144        text.to_string()
145    }
146}
147
148// ── Composite icons ─────────────────────────────────────────────────────────
149//
150// openclaw uses ✓ / ✗ / ⚠ with colour.
151
152/// Green ✓
153pub fn icon_ok(label: &str) -> String {
154    format!("{} {}", success("✓"), label)
155}
156
157/// Red ✗
158pub fn icon_fail(label: &str) -> String {
159    format!("{} {}", error("✗"), label)
160}
161
162/// Yellow ⚠
163pub fn icon_warn(label: &str) -> String {
164    format!("{} {}", warn("⚠"), label)
165}
166
167/// Muted dash —
168pub fn icon_muted(label: &str) -> String {
169    format!("{} {}", muted("·"), muted(label))
170}
171
172// ── Labelled key : value ────────────────────────────────────────────────────
173
174/// Format "  Label  : value" with the label dimmed and the value in accent.
175pub fn label_value(label: &str, value: &str) -> String {
176    format!("  {} : {}", muted(label), info(value))
177}
178
179// ── Spinner helpers ─────────────────────────────────────────────────────────
180
181/// Spinner character set mimicking openclaw's clack spinners.
182const SPINNER_CHARS: &[&str] = &["◒", "◐", "◓", "◑"];
183
184/// Create an indeterminate spinner with a message.
185///
186/// Returns a `ProgressBar` that the caller should call `.finish_with_message()`
187/// or `.finish_and_clear()` on when done.
188pub fn spinner(message: &str) -> ProgressBar {
189    let pb = ProgressBar::new_spinner();
190    let style = if is_color() {
191        ProgressStyle::with_template(&format!(
192            "{{spinner:.{}}}  {{msg}}",
193            "red" // indicatif colour name closest to lobster accent
194        ))
195        .unwrap()
196        .tick_strings(SPINNER_CHARS)
197    } else {
198        ProgressStyle::with_template("{spinner}  {msg}")
199            .unwrap()
200            .tick_strings(SPINNER_CHARS)
201    };
202    pb.set_style(style);
203    pb.set_message(message.to_string());
204    pb.enable_steady_tick(Duration::from_millis(80));
205    pb
206}
207
208/// Finish a spinner with a success icon + message.
209pub fn spinner_ok(pb: &ProgressBar, message: &str) {
210    pb.finish_with_message(icon_ok(message));
211}
212
213/// Finish a spinner with a failure icon + message.
214pub fn spinner_fail(pb: &ProgressBar, message: &str) {
215    pb.finish_with_message(icon_fail(message));
216}
217
218/// Finish a spinner with a warning icon + message.
219pub fn spinner_warn(pb: &ProgressBar, message: &str) {
220    pb.finish_with_message(icon_warn(message));
221}
222
223// ── Box drawing (for onboarding banner etc.) ────────────────────────────────
224
225/// Print a styled header box (like openclaw's `intro()` from clack).
226pub fn print_header(title: &str) {
227    use unicode_width::UnicodeWidthStr;
228
229    let display_w = UnicodeWidthStr::width(title);
230    // Inner width = display width of title + at least 4 chars padding (2 each side)
231    let inner = (display_w + 4).max(42);
232    let pad = inner - display_w;
233    let left = pad / 2;
234    let right = pad - left;
235    println!();
236    println!("{}", accent(&format!("┌{}┐", "─".repeat(inner))));
237    println!(
238        "{}",
239        accent(&format!(
240            "│{}{}{}│",
241            " ".repeat(left),
242            title,
243            " ".repeat(right)
244        ))
245    );
246    println!("{}", accent(&format!("└{}┘", "─".repeat(inner))));
247    println!();
248}
249
250// ── Tests ───────────────────────────────────────────────────────────────────
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_no_color_output() {
258        // Force no-color mode (both our flag AND the colored crate).
259        COLOR_DISABLED.store(true, Ordering::Relaxed);
260        colored::control::set_override(false);
261        assert_eq!(accent("hello"), "hello");
262        assert_eq!(success("ok"), "ok");
263        assert_eq!(error("fail"), "fail");
264        assert_eq!(icon_ok("done"), "✓ done");
265        assert_eq!(icon_fail("bad"), "✗ bad");
266        // Reset for other tests.
267        colored::control::unset_override();
268        COLOR_DISABLED.store(false, Ordering::Relaxed);
269    }
270
271    #[test]
272    fn test_label_value() {
273        COLOR_DISABLED.store(true, Ordering::Relaxed);
274        let out = label_value("Key", "/some/path");
275        assert!(out.contains("Key"));
276        assert!(out.contains("/some/path"));
277        COLOR_DISABLED.store(false, Ordering::Relaxed);
278    }
279}
280
281// ── Ratatui palette ─────────────────────────────────────────────────────────
282//
283// The TUI palette has moved to the `rustyclaw-tui` crate.
284// See `crates/rustyclaw-tui/src/tui_palette.rs`.