Skip to main content

roboticus_core/
style.rs

1use std::io::IsTerminal;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum ColorMode {
5    Auto,
6    Always,
7    Never,
8}
9
10impl ColorMode {
11    pub fn from_flag(s: &str) -> Self {
12        match s {
13            "always" => Self::Always,
14            "never" => Self::Never,
15            _ => Self::Auto,
16        }
17    }
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ThemeVariant {
22    CrtGreen,
23    CrtOrange,
24    Terminal,
25}
26
27impl ThemeVariant {
28    pub fn from_flag(s: &str) -> Self {
29        match s {
30            "crt-orange" => Self::CrtOrange,
31            "terminal" => Self::Terminal,
32            _ => Self::CrtGreen,
33        }
34    }
35}
36
37/// CLI theme with selectable color palettes and optional typewriter effects.
38///
39/// Precedence: `--color` flag > `NO_COLOR` env var > TTY auto-detection.
40/// Draw (typewriter) is enabled by default on interactive TTY, disabled with `--no-draw`.
41#[derive(Debug, Clone)]
42pub struct Theme {
43    enabled: bool,
44    draw: bool,
45    variant: ThemeVariant,
46    nerdmode: bool,
47}
48
49impl Theme {
50    pub fn detect() -> Self {
51        Self::resolve(ColorMode::Auto, ThemeVariant::CrtGreen)
52    }
53
54    pub fn from_flags(color_flag: &str, theme_flag: &str) -> Self {
55        Self::resolve(
56            ColorMode::from_flag(color_flag),
57            ThemeVariant::from_flag(theme_flag),
58        )
59    }
60
61    pub fn resolve(mode: ColorMode, variant: ThemeVariant) -> Self {
62        let enabled = match mode {
63            ColorMode::Always => true,
64            ColorMode::Never => false,
65            ColorMode::Auto => {
66                let no_color = std::env::var("NO_COLOR")
67                    .map(|v| !v.is_empty())
68                    .unwrap_or(false);
69                if no_color {
70                    false
71                } else {
72                    std::io::stderr().is_terminal()
73                }
74            }
75        };
76        Self {
77            enabled,
78            draw: enabled,
79            variant,
80            nerdmode: false,
81        }
82    }
83
84    pub fn plain() -> Self {
85        Self {
86            enabled: false,
87            draw: false,
88            variant: ThemeVariant::CrtGreen,
89            nerdmode: false,
90        }
91    }
92
93    pub fn with_draw(mut self, draw: bool) -> Self {
94        self.draw = draw;
95        self
96    }
97
98    pub fn with_nerdmode(mut self, nerd: bool) -> Self {
99        if nerd {
100            self.nerdmode = true;
101            self.draw = true;
102            if self.variant == ThemeVariant::Terminal {
103                self.variant = ThemeVariant::CrtGreen;
104            }
105        }
106        self
107    }
108
109    pub fn colors_enabled(&self) -> bool {
110        self.enabled
111    }
112
113    pub fn draw_enabled(&self) -> bool {
114        self.draw
115    }
116
117    pub fn variant(&self) -> ThemeVariant {
118        self.variant
119    }
120
121    pub fn nerdmode(&self) -> bool {
122        self.nerdmode
123    }
124
125    // ── Icon Accessors ───────────────────────────────────────────
126    // Return emoji by default; ASCII when nerdmode is active.
127
128    pub fn icon_ok(&self) -> &'static str {
129        if self.nerdmode { "[OK]" } else { "\u{2705}" }
130    }
131
132    pub fn icon_action(&self) -> &'static str {
133        if self.nerdmode {
134            "[>>]"
135        } else {
136            "\u{26a1}\u{fe0f}"
137        }
138    }
139
140    pub fn icon_warn(&self) -> &'static str {
141        if self.nerdmode {
142            "[!!]"
143        } else {
144            "\u{26a0}\u{fe0f}"
145        }
146    }
147
148    pub fn icon_detail(&self) -> &'static str {
149        if self.nerdmode { ">" } else { "\u{25b8}" }
150    }
151
152    pub fn icon_error(&self) -> &'static str {
153        if self.nerdmode { "[XX]" } else { "\u{26d3}" }
154    }
155
156    // ── Color Palette ────────────────────────────────────────────
157
158    /// Emphasis/highlight color. Bright green, bright orange, or bold depending on variant.
159    pub fn accent(&self) -> &'static str {
160        if !self.enabled {
161            return "";
162        }
163        match self.variant {
164            ThemeVariant::CrtGreen => "\x1b[38;5;46m",
165            ThemeVariant::CrtOrange => "\x1b[38;5;208m",
166            ThemeVariant::Terminal => "\x1b[1m",
167        }
168    }
169
170    /// Body-text color. Matches the variant's base tone; empty for Terminal.
171    pub fn dim(&self) -> &'static str {
172        if !self.enabled {
173            return "";
174        }
175        match self.variant {
176            ThemeVariant::CrtGreen => "\x1b[38;5;40m",
177            ThemeVariant::CrtOrange => "\x1b[38;5;172m",
178            ThemeVariant::Terminal => "",
179        }
180    }
181
182    /// Monospace-value color. Same as accent for CRT variants; bold for Terminal.
183    pub fn mono(&self) -> &'static str {
184        if !self.enabled {
185            return "";
186        }
187        match self.variant {
188            ThemeVariant::CrtGreen => "\x1b[38;5;46m",
189            ThemeVariant::CrtOrange => "\x1b[38;5;208m",
190            ThemeVariant::Terminal => "\x1b[1m",
191        }
192    }
193
194    /// Bright green. Explicit "all passed" summaries, enabled/active states.
195    pub fn success(&self) -> &'static str {
196        if self.enabled { "\x1b[92m" } else { "" }
197    }
198
199    /// Bright yellow. Warnings, fallback states, skipped items.
200    pub fn warn(&self) -> &'static str {
201        if self.enabled { "\x1b[93m" } else { "" }
202    }
203
204    /// Bright red. Errors, failures, disabled states.
205    pub fn error(&self) -> &'static str {
206        if self.enabled { "\x1b[91m" } else { "" }
207    }
208
209    /// Bright cyan. Auto-fix actions, debug info, discovery output.
210    pub fn info(&self) -> &'static str {
211        if self.enabled { "\x1b[96m" } else { "" }
212    }
213
214    // ── Typography modifiers ─────────────────────────────────────
215
216    pub fn bold(&self) -> &'static str {
217        if self.enabled { "\x1b[1m" } else { "" }
218    }
219
220    /// Soft reset: clears styles and re-tints to the variant's body color.
221    /// For Terminal variant, this is a plain reset (no tint).
222    pub fn reset(&self) -> &'static str {
223        if !self.enabled {
224            return "";
225        }
226        match self.variant {
227            ThemeVariant::CrtGreen => "\x1b[0m\x1b[38;5;40m",
228            ThemeVariant::CrtOrange => "\x1b[0m\x1b[38;5;172m",
229            ThemeVariant::Terminal => "\x1b[0m",
230        }
231    }
232
233    /// Hard reset: returns terminal to default colors. Use at program exit.
234    pub fn hard_reset(&self) -> &'static str {
235        if self.enabled { "\x1b[0m" } else { "" }
236    }
237
238    // ── Typewriter Effects ───────────────────────────────────────
239
240    /// Typewrite to stderr, character-by-character. ANSI sequences emitted instantly.
241    /// Skips delay when draw is disabled (instant print).
242    pub fn typewrite(&self, text: &str, delay_ms: u64) {
243        use std::io::Write;
244        if !self.draw {
245            eprint!("{text}");
246            return;
247        }
248        let delay = std::time::Duration::from_millis(delay_ms);
249        let mut chars = text.chars();
250        while let Some(ch) = chars.next() {
251            if ch == '\x1b' {
252                let mut seq = String::from(ch);
253                for c in chars.by_ref() {
254                    seq.push(c);
255                    if c == 'm' {
256                        break;
257                    }
258                }
259                eprint!("{seq}");
260            } else if ch == '\n' {
261                eprintln!();
262            } else {
263                eprint!("{ch}");
264                std::io::stderr().flush().ok();
265                std::thread::sleep(delay);
266            }
267        }
268    }
269
270    /// Typewrite to stderr + newline.
271    pub fn typewrite_line(&self, text: &str, delay_ms: u64) {
272        self.typewrite(text, delay_ms);
273        eprintln!();
274    }
275
276    /// Typewrite to stdout, character-by-character.
277    pub fn typewrite_stdout(&self, text: &str, delay_ms: u64) {
278        use std::io::Write;
279        if !self.draw {
280            print!("{text}");
281            return;
282        }
283        let delay = std::time::Duration::from_millis(delay_ms);
284        let mut chars = text.chars();
285        while let Some(ch) = chars.next() {
286            if ch == '\x1b' {
287                let mut seq = String::from(ch);
288                for c in chars.by_ref() {
289                    seq.push(c);
290                    if c == 'm' {
291                        break;
292                    }
293                }
294                print!("{seq}");
295            } else if ch == '\n' {
296                println!();
297            } else {
298                print!("{ch}");
299                std::io::stdout().flush().ok();
300                std::thread::sleep(delay);
301            }
302        }
303    }
304
305    /// Typewrite to stdout + newline.
306    pub fn typewrite_line_stdout(&self, text: &str, delay_ms: u64) {
307        use std::io::Write;
308        self.typewrite_stdout(text, delay_ms);
309        println!();
310        std::io::stdout().flush().ok();
311    }
312}
313
314/// Convenience sleep in milliseconds.
315pub fn sleep_ms(ms: u64) {
316    std::thread::sleep(std::time::Duration::from_millis(ms));
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn plain_theme_returns_empty_strings() {
325        let t = Theme::plain();
326        assert!(!t.colors_enabled());
327        assert!(!t.draw_enabled());
328        assert_eq!(t.accent(), "");
329        assert_eq!(t.dim(), "");
330        assert_eq!(t.mono(), "");
331        assert_eq!(t.success(), "");
332        assert_eq!(t.warn(), "");
333        assert_eq!(t.error(), "");
334        assert_eq!(t.info(), "");
335        assert_eq!(t.bold(), "");
336        assert_eq!(t.reset(), "");
337        assert_eq!(t.hard_reset(), "");
338    }
339
340    #[test]
341    fn always_mode_forces_color_and_draw() {
342        let t = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen);
343        assert!(t.colors_enabled());
344        assert!(t.draw_enabled());
345        assert!(!t.accent().is_empty());
346        assert!(!t.reset().is_empty());
347    }
348
349    #[test]
350    fn with_draw_false_disables_draw() {
351        let t = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen).with_draw(false);
352        assert!(t.colors_enabled());
353        assert!(!t.draw_enabled());
354    }
355
356    #[test]
357    fn crt_green_reset_includes_green_tint() {
358        let t = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen);
359        let r = t.reset();
360        assert!(r.contains("\x1b[0m"), "reset should clear styles");
361        assert!(
362            r.contains("\x1b[38;5;40m"),
363            "CrtGreen reset should tint green"
364        );
365    }
366
367    #[test]
368    fn crt_orange_palette() {
369        let t = Theme::resolve(ColorMode::Always, ThemeVariant::CrtOrange);
370        assert!(t.accent().contains("208"), "CrtOrange accent should be 208");
371        assert!(t.dim().contains("172"), "CrtOrange dim should be 172");
372        assert!(
373            t.reset().contains("172"),
374            "CrtOrange reset should tint orange"
375        );
376    }
377
378    #[test]
379    fn terminal_variant_no_tint() {
380        let t = Theme::resolve(ColorMode::Always, ThemeVariant::Terminal);
381        assert_eq!(t.reset(), "\x1b[0m", "Terminal reset should be plain");
382        assert_eq!(t.dim(), "", "Terminal dim should be empty");
383        assert_eq!(t.accent(), "\x1b[1m", "Terminal accent should be bold");
384    }
385
386    #[test]
387    fn hard_reset_is_plain() {
388        let t = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen);
389        assert_eq!(t.hard_reset(), "\x1b[0m");
390    }
391
392    #[test]
393    fn never_mode_disables_everything() {
394        let t = Theme::resolve(ColorMode::Never, ThemeVariant::CrtGreen);
395        assert!(!t.colors_enabled());
396        assert!(!t.draw_enabled());
397        assert_eq!(t.accent(), "");
398    }
399
400    #[test]
401    fn from_flag_parses_correctly() {
402        assert_eq!(ColorMode::from_flag("always"), ColorMode::Always);
403        assert_eq!(ColorMode::from_flag("never"), ColorMode::Never);
404        assert_eq!(ColorMode::from_flag("auto"), ColorMode::Auto);
405        assert_eq!(ColorMode::from_flag("garbage"), ColorMode::Auto);
406    }
407
408    #[test]
409    fn theme_variant_from_flag() {
410        assert_eq!(ThemeVariant::from_flag("crt-green"), ThemeVariant::CrtGreen);
411        assert_eq!(
412            ThemeVariant::from_flag("crt-orange"),
413            ThemeVariant::CrtOrange
414        );
415        assert_eq!(ThemeVariant::from_flag("terminal"), ThemeVariant::Terminal);
416        assert_eq!(ThemeVariant::from_flag("garbage"), ThemeVariant::CrtGreen);
417    }
418
419    #[test]
420    fn semantic_colors_same_across_variants() {
421        let green = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen);
422        let orange = Theme::resolve(ColorMode::Always, ThemeVariant::CrtOrange);
423        let term = Theme::resolve(ColorMode::Always, ThemeVariant::Terminal);
424
425        assert_eq!(green.success(), orange.success());
426        assert_eq!(green.success(), term.success());
427        assert_eq!(green.warn(), orange.warn());
428        assert_eq!(green.error(), orange.error());
429        assert_eq!(green.info(), orange.info());
430    }
431
432    #[test]
433    fn nerdmode_forces_ascii_icons() {
434        let t = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen).with_nerdmode(true);
435        assert_eq!(t.icon_ok(), "[OK]");
436        assert_eq!(t.icon_action(), "[>>]");
437        assert_eq!(t.icon_warn(), "[!!]");
438        assert_eq!(t.icon_detail(), ">");
439        assert_eq!(t.icon_error(), "[XX]");
440    }
441
442    #[test]
443    fn nerdmode_overrides_terminal_to_green() {
444        let t = Theme::resolve(ColorMode::Always, ThemeVariant::Terminal).with_nerdmode(true);
445        assert_eq!(t.variant(), ThemeVariant::CrtGreen);
446        assert!(t.reset().contains("\x1b[38;5;40m"));
447    }
448
449    #[test]
450    fn nerdmode_respects_orange() {
451        let t = Theme::resolve(ColorMode::Always, ThemeVariant::CrtOrange).with_nerdmode(true);
452        assert_eq!(t.variant(), ThemeVariant::CrtOrange);
453        assert!(t.accent().contains("208"));
454    }
455
456    #[test]
457    fn nerdmode_forces_draw() {
458        let t = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen)
459            .with_draw(false)
460            .with_nerdmode(true);
461        assert!(t.draw_enabled());
462    }
463
464    #[test]
465    fn default_icons_are_emoji() {
466        let t = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen);
467        assert!(!t.nerdmode());
468        assert_eq!(t.icon_ok(), "\u{2705}");
469        assert_eq!(t.icon_action(), "\u{26a1}\u{fe0f}");
470        assert_eq!(t.icon_warn(), "\u{26a0}\u{fe0f}");
471        assert_eq!(t.icon_detail(), "\u{25b8}");
472        assert_eq!(t.icon_error(), "\u{26d3}");
473    }
474
475    #[test]
476    fn from_flags_produces_correct_theme() {
477        let t = Theme::from_flags("always", "crt-orange");
478        assert!(t.colors_enabled());
479        assert_eq!(t.variant(), ThemeVariant::CrtOrange);
480
481        let t2 = Theme::from_flags("never", "terminal");
482        assert!(!t2.colors_enabled());
483        assert_eq!(t2.variant(), ThemeVariant::Terminal);
484    }
485
486    #[test]
487    fn from_flags_unknown_defaults() {
488        let t = Theme::from_flags("auto", "garbage");
489        assert_eq!(t.variant(), ThemeVariant::CrtGreen);
490    }
491
492    #[test]
493    fn detect_returns_a_theme() {
494        let t = Theme::detect();
495        assert_eq!(t.variant(), ThemeVariant::CrtGreen);
496    }
497
498    #[test]
499    fn mono_colors_per_variant() {
500        let green = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen);
501        assert_eq!(green.mono(), "\x1b[38;5;46m");
502
503        let orange = Theme::resolve(ColorMode::Always, ThemeVariant::CrtOrange);
504        assert_eq!(orange.mono(), "\x1b[38;5;208m");
505
506        let term = Theme::resolve(ColorMode::Always, ThemeVariant::Terminal);
507        assert_eq!(term.mono(), "\x1b[1m");
508    }
509
510    #[test]
511    fn typewrite_instant_when_draw_disabled() {
512        let t = Theme::plain();
513        assert!(!t.draw_enabled());
514        t.typewrite("hello", 100);
515        t.typewrite("with \x1b[1m ansi \x1b[0m codes", 100);
516    }
517
518    #[test]
519    fn typewrite_line_instant_when_draw_disabled() {
520        let t = Theme::plain();
521        t.typewrite_line("hello line", 100);
522    }
523
524    #[test]
525    fn typewrite_stdout_instant_when_draw_disabled() {
526        let t = Theme::plain();
527        t.typewrite_stdout("stdout text", 100);
528    }
529
530    #[test]
531    fn typewrite_line_stdout_instant_when_draw_disabled() {
532        let t = Theme::plain();
533        t.typewrite_line_stdout("stdout line", 100);
534    }
535
536    #[test]
537    fn typewrite_with_draw_enabled_processes_ansi() {
538        let t = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen);
539        assert!(t.draw_enabled());
540        t.typewrite("ab\x1b[1mc\x1b[0m\nend", 0);
541    }
542
543    #[test]
544    fn typewrite_stdout_with_draw_enabled() {
545        let t = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen);
546        t.typewrite_stdout("ab\x1b[1mc\x1b[0m\nend", 0);
547    }
548
549    #[test]
550    fn sleep_ms_does_not_panic() {
551        sleep_ms(0);
552        sleep_ms(1);
553    }
554
555    #[test]
556    fn with_draw_true_enables_draw() {
557        let t = Theme::plain().with_draw(true);
558        assert!(t.draw_enabled());
559    }
560
561    #[test]
562    fn nerdmode_false_is_noop() {
563        let t = Theme::resolve(ColorMode::Always, ThemeVariant::Terminal).with_nerdmode(false);
564        assert!(!t.nerdmode());
565    }
566
567    #[test]
568    fn semantic_colors_plain_theme() {
569        let t = Theme::plain();
570        assert_eq!(t.success(), "");
571        assert_eq!(t.warn(), "");
572        assert_eq!(t.error(), "");
573        assert_eq!(t.info(), "");
574        assert_eq!(t.bold(), "");
575    }
576
577    #[test]
578    fn dim_per_variant() {
579        let green = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen);
580        assert_eq!(green.dim(), "\x1b[38;5;40m");
581
582        let orange = Theme::resolve(ColorMode::Always, ThemeVariant::CrtOrange);
583        assert_eq!(orange.dim(), "\x1b[38;5;172m");
584
585        let term = Theme::resolve(ColorMode::Always, ThemeVariant::Terminal);
586        assert_eq!(term.dim(), "");
587    }
588
589    #[test]
590    fn reset_per_variant() {
591        let green = Theme::resolve(ColorMode::Always, ThemeVariant::CrtGreen);
592        assert!(green.reset().contains("\x1b[38;5;40m"));
593
594        let orange = Theme::resolve(ColorMode::Always, ThemeVariant::CrtOrange);
595        assert!(orange.reset().contains("\x1b[38;5;172m"));
596
597        let term = Theme::resolve(ColorMode::Always, ThemeVariant::Terminal);
598        assert_eq!(term.reset(), "\x1b[0m");
599    }
600}