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#[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 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 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 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 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 pub fn success(&self) -> &'static str {
196 if self.enabled { "\x1b[92m" } else { "" }
197 }
198
199 pub fn warn(&self) -> &'static str {
201 if self.enabled { "\x1b[93m" } else { "" }
202 }
203
204 pub fn error(&self) -> &'static str {
206 if self.enabled { "\x1b[91m" } else { "" }
207 }
208
209 pub fn info(&self) -> &'static str {
211 if self.enabled { "\x1b[96m" } else { "" }
212 }
213
214 pub fn bold(&self) -> &'static str {
217 if self.enabled { "\x1b[1m" } else { "" }
218 }
219
220 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 pub fn hard_reset(&self) -> &'static str {
235 if self.enabled { "\x1b[0m" } else { "" }
236 }
237
238 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 pub fn typewrite_line(&self, text: &str, delay_ms: u64) {
272 self.typewrite(text, delay_ms);
273 eprintln!();
274 }
275
276 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 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
314pub 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}