Skip to main content

doing_template/
colors.rs

1use std::{
2  fmt::{Display, Formatter, Result as FmtResult},
3  sync::LazyLock,
4};
5
6use regex::Regex;
7use yansi::{Condition, Style};
8
9pub static STRIP_ANSI_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").unwrap());
10
11/// A named ANSI color or modifier that can appear as a `%color` token in templates.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub enum Color {
14  Hex { b: u8, background: bool, g: u8, r: u8 },
15  Named(NamedColor),
16}
17
18impl Color {
19  /// Parse a color name string into a `Color`, if valid.
20  ///
21  /// Accepts named colors (`"cyan"`, `"boldwhite"`), modifiers (`"bold"`, `"italic"`),
22  /// and hex RGB (`"#FF5500"`, `"bg#00FF00"`).
23  pub fn parse(s: &str) -> Option<Self> {
24    if let Some(hex) = parse_hex(s) {
25      return Some(hex);
26    }
27    NamedColor::parse(s).map(Self::Named)
28  }
29
30  /// Return the ANSI escape sequence for this color.
31  pub fn to_ansi(&self) -> String {
32    match self {
33      Self::Hex {
34        background,
35        b,
36        g,
37        r,
38      } => {
39        let style = if *background {
40          Style::new().on_rgb(*r, *g, *b)
41        } else {
42          Style::new().rgb(*r, *g, *b)
43        };
44        style.prefix().to_string()
45      }
46      Self::Named(named) => named.to_ansi(),
47    }
48  }
49}
50
51impl Display for Color {
52  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
53    write!(f, "{}", self.to_ansi())
54  }
55}
56
57/// A named ANSI color attribute.
58#[allow(dead_code)]
59#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
60pub enum NamedColor {
61  Alert,
62  BgBlack,
63  BgBlue,
64  BgCyan,
65  BgGreen,
66  BgMagenta,
67  BgPurple,
68  BgRed,
69  BgWhite,
70  BgYellow,
71  Black,
72  Blink,
73  Blue,
74  Bold,
75  BoldBgBlack,
76  BoldBgBlue,
77  BoldBgCyan,
78  BoldBgGreen,
79  BoldBgMagenta,
80  BoldBgPurple,
81  BoldBgRed,
82  BoldBgWhite,
83  BoldBgYellow,
84  BoldBlack,
85  BoldBlue,
86  BoldCyan,
87  BoldGreen,
88  BoldMagenta,
89  BoldPurple,
90  BoldRed,
91  BoldWhite,
92  BoldYellow,
93  Chalkboard,
94  Concealed,
95  Cyan,
96  Dark,
97  Default,
98  Error,
99  Flamingo,
100  Green,
101  Hotpants,
102  Italic,
103  Knightrider,
104  Led,
105  Magenta,
106  Negative,
107  Purple,
108  RapidBlink,
109  Red,
110  Redacted,
111  Reset,
112  Softpurple,
113  Strike,
114  Strikethrough,
115  Underline,
116  Underscore,
117  White,
118  Whiteboard,
119  Yeller,
120  Yellow,
121}
122
123impl NamedColor {
124  /// Parse a string into a `NamedColor`, if it matches a known color name.
125  pub fn parse(s: &str) -> Option<Self> {
126    let normalized = s.replace('_', "").replace("bright", "bold");
127    let normalized = if normalized.starts_with("bgbold") {
128      normalized.replacen("bgbold", "boldbg", 1)
129    } else {
130      normalized
131    };
132    match normalized.as_str() {
133      "alert" => Some(Self::Alert),
134      "bgblack" => Some(Self::BgBlack),
135      "bgblue" => Some(Self::BgBlue),
136      "bgcyan" => Some(Self::BgCyan),
137      "bggreen" => Some(Self::BgGreen),
138      "bgmagenta" | "bgpurple" => Some(Self::BgMagenta),
139      "bgred" => Some(Self::BgRed),
140      "bgwhite" => Some(Self::BgWhite),
141      "bgyellow" => Some(Self::BgYellow),
142      "black" => Some(Self::Black),
143      "blink" => Some(Self::Blink),
144      "blue" => Some(Self::Blue),
145      "bold" => Some(Self::Bold),
146      "boldbgblack" => Some(Self::BoldBgBlack),
147      "boldbgblue" => Some(Self::BoldBgBlue),
148      "boldbgcyan" => Some(Self::BoldBgCyan),
149      "boldbggreen" => Some(Self::BoldBgGreen),
150      "boldbgmagenta" | "boldbgpurple" => Some(Self::BoldBgMagenta),
151      "boldbgred" => Some(Self::BoldBgRed),
152      "boldbgwhite" => Some(Self::BoldBgWhite),
153      "boldbgyellow" => Some(Self::BoldBgYellow),
154      "boldblack" => Some(Self::BoldBlack),
155      "boldblue" => Some(Self::BoldBlue),
156      "boldcyan" => Some(Self::BoldCyan),
157      "boldgreen" => Some(Self::BoldGreen),
158      "boldmagenta" | "boldpurple" => Some(Self::BoldMagenta),
159      "boldred" => Some(Self::BoldRed),
160      "boldwhite" => Some(Self::BoldWhite),
161      "boldyellow" => Some(Self::BoldYellow),
162      "chalkboard" => Some(Self::Chalkboard),
163      "clear" | "reset" => Some(Self::Reset),
164      "concealed" => Some(Self::Concealed),
165      "cyan" => Some(Self::Cyan),
166      "dark" => Some(Self::Dark),
167      "default" => Some(Self::Default),
168      "error" => Some(Self::Error),
169      "flamingo" => Some(Self::Flamingo),
170      "green" => Some(Self::Green),
171      "hotpants" => Some(Self::Hotpants),
172      "italic" => Some(Self::Italic),
173      "knightrider" => Some(Self::Knightrider),
174      "led" => Some(Self::Led),
175      "magenta" | "purple" => Some(Self::Magenta),
176      "negative" => Some(Self::Negative),
177      "rapidblink" => Some(Self::RapidBlink),
178      "red" => Some(Self::Red),
179      "redacted" => Some(Self::Redacted),
180      "softpurple" => Some(Self::Softpurple),
181      "strike" => Some(Self::Strike),
182      "strikethrough" => Some(Self::Strikethrough),
183      "underline" | "underscore" => Some(Self::Underline),
184      "white" => Some(Self::White),
185      "whiteboard" => Some(Self::Whiteboard),
186      "yeller" => Some(Self::Yeller),
187      "yellow" => Some(Self::Yellow),
188      _ => None,
189    }
190  }
191
192  /// Return the ANSI escape sequence for this named color.
193  fn to_ansi(self) -> String {
194    if !yansi::is_enabled() {
195      return String::new();
196    }
197    // Reset and default need raw ANSI since yansi styles compose forward
198    match self {
199      Self::Default => "\x1b[0;39m".into(),
200      Self::Reset => "\x1b[0m".into(),
201      _ => self.to_style().prefix().to_string(),
202    }
203  }
204
205  /// Convert this named color into a [`yansi::Style`].
206  ///
207  /// For most colors, this maps directly to yansi's `Color` and `Attribute` types.
208  /// Compound themes (e.g. `chalkboard`, `flamingo`) compose multiple style properties.
209  fn to_style(self) -> Style {
210    match self {
211      Self::Alert => Style::new().red().on_yellow().bold(),
212      Self::BgBlack => Style::new().on_black(),
213      Self::BgBlue => Style::new().on_blue(),
214      Self::BgCyan => Style::new().on_cyan(),
215      Self::BgGreen => Style::new().on_green(),
216      Self::BgMagenta | Self::BgPurple => Style::new().on_magenta(),
217      Self::BgRed => Style::new().on_red(),
218      Self::BgWhite => Style::new().on_white(),
219      Self::BgYellow => Style::new().on_yellow(),
220      Self::Black => Style::new().black(),
221      Self::Blink => Style::new().blink(),
222      Self::Blue => Style::new().blue(),
223      Self::Bold => Style::new().bold(),
224      Self::BoldBgBlack => Style::new().on_bright_black(),
225      Self::BoldBgBlue => Style::new().on_bright_blue(),
226      Self::BoldBgCyan => Style::new().on_bright_cyan(),
227      Self::BoldBgGreen => Style::new().on_bright_green(),
228      Self::BoldBgMagenta | Self::BoldBgPurple => Style::new().on_bright_magenta(),
229      Self::BoldBgRed => Style::new().on_bright_red(),
230      Self::BoldBgWhite => Style::new().on_bright_white(),
231      Self::BoldBgYellow => Style::new().on_bright_yellow(),
232      Self::BoldBlack => Style::new().bright_black(),
233      Self::BoldBlue => Style::new().bright_blue(),
234      Self::BoldCyan => Style::new().bright_cyan(),
235      Self::BoldGreen => Style::new().bright_green(),
236      Self::BoldMagenta | Self::BoldPurple => Style::new().bright_magenta(),
237      Self::BoldRed => Style::new().bright_red(),
238      Self::BoldWhite => Style::new().bright_white(),
239      Self::BoldYellow => Style::new().bright_yellow(),
240      Self::Chalkboard => Style::new().white().on_black().bold(),
241      Self::Concealed => Style::new().conceal(),
242      Self::Cyan => Style::new().cyan(),
243      Self::Dark => Style::new().dim(),
244      Self::Default | Self::Reset => Style::new(),
245      Self::Error => Style::new().white().on_red().bold(),
246      Self::Flamingo => Style::new().red().on_white().invert(),
247      Self::Green => Style::new().green(),
248      Self::Hotpants => Style::new().blue().on_black().invert(),
249      Self::Italic => Style::new().italic(),
250      Self::Knightrider => Style::new().black().on_black().invert(),
251      Self::Led => Style::new().green().on_black(),
252      Self::Magenta | Self::Purple => Style::new().magenta(),
253      Self::Negative => Style::new().invert(),
254      Self::RapidBlink => Style::new().rapid_blink(),
255      Self::Red => Style::new().red(),
256      Self::Redacted => Style::new().black().on_black(),
257      Self::Softpurple => Style::new().magenta().on_black(),
258      Self::Strike | Self::Strikethrough => Style::new().strike(),
259      Self::Underline | Self::Underscore => Style::new().underline(),
260      Self::White => Style::new().white(),
261      Self::Whiteboard => Style::new().black().on_white().bold(),
262      Self::Yeller => Style::new().white().on_yellow().bold(),
263      Self::Yellow => Style::new().yellow(),
264    }
265  }
266}
267
268impl Display for NamedColor {
269  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
270    write!(f, "{}", self.to_ansi())
271  }
272}
273
274/// Initialize color output with terminal detection.
275///
276/// Call this once at program startup to enable automatic TTY and `NO_COLOR` / `CLICOLOR`
277/// detection via [`yansi`]. When output is piped or redirected, colors are suppressed.
278pub fn init() {
279  yansi::whenever(Condition::TTY_AND_COLOR);
280}
281
282/// Strip all ANSI escape sequences from a string.
283pub fn strip_ansi(s: &str) -> String {
284  STRIP_ANSI_RE.replace_all(s, "").into_owned()
285}
286
287/// Validate a color name string, returning the longest valid prefix and its original length.
288///
289/// Returns a tuple of (normalized_color_name, original_byte_length) so the parser
290/// can compute correct span offsets even when underscores are stripped during normalization.
291///
292/// This allows color tokens to bleed into adjacent text, e.g. `%greensomething`
293/// still matches `green`.
294pub fn validate_color(s: &str) -> Option<(String, usize)> {
295  let normalized = s.replace('_', "").replace("bright", "bold");
296  let normalized = if normalized.starts_with("bgbold") {
297    normalized.replacen("bgbold", "boldbg", 1)
298  } else {
299    normalized
300  };
301
302  // Check for hex color
303  if let Some(rest) = normalized.strip_prefix('#')
304    && rest.len() >= 6
305    && rest[..6].chars().all(|c| c.is_ascii_hexdigit())
306  {
307    let color = format!("#{}", &rest[..6]);
308    let orig_len = original_len_for_normalized(s, color.len());
309    return Some((color, orig_len));
310  }
311  for prefix in ["fg#", "bg#", "f#", "b#"] {
312    if let Some(rest) = normalized.strip_prefix(prefix)
313      && rest.len() >= 6
314      && rest[..6].chars().all(|c| c.is_ascii_hexdigit())
315    {
316      let color = format!("{prefix}{}", &rest[..6]);
317      let orig_len = original_len_for_normalized(s, color.len());
318      return Some((color, orig_len));
319    }
320  }
321
322  // Find longest matching named color
323  let mut valid = None;
324  let mut compiled = String::new();
325  let mut norm_len = 0;
326  for ch in normalized.chars() {
327    compiled.push(ch);
328    norm_len += ch.len_utf8();
329    if NamedColor::parse(&compiled).is_some() {
330      valid = Some((compiled.clone(), original_len_for_normalized(s, norm_len)));
331    }
332  }
333  valid
334}
335
336/// Map a normalized byte length back to the original input byte length,
337/// accounting for underscores stripped and "bright" → "bold" replacement.
338fn original_len_for_normalized(original: &str, normalized_len: usize) -> usize {
339  // First strip underscores, tracking original byte offsets
340  let stripped: String = original.chars().filter(|&c| c != '_').collect();
341  let mut stripped_to_orig = Vec::new();
342  let mut orig_pos = 0;
343  for ch in original.chars() {
344    if ch != '_' {
345      stripped_to_orig.push(orig_pos);
346    }
347    orig_pos += ch.len_utf8();
348  }
349  // sentinel for "consumed everything"
350  stripped_to_orig.push(orig_pos);
351
352  // Walk both the normalized and stripped strings to find how many stripped chars
353  // correspond to normalized_len normalized bytes
354  let mut norm_pos = 0;
355  let mut strip_pos = 0;
356  while norm_pos < normalized_len && strip_pos < stripped.len() {
357    if stripped[strip_pos..].starts_with("bright") {
358      // "bright" (6 stripped chars) became "bold" (4 normalized chars)
359      let norm_advance = "bold".len().min(normalized_len - norm_pos);
360      norm_pos += norm_advance;
361      if norm_advance == "bold".len() {
362        strip_pos += "bright".len();
363      } else {
364        // partial match within "bold" — consume proportional stripped chars
365        strip_pos += norm_advance;
366      }
367    } else {
368      let ch_len = stripped[strip_pos..].chars().next().map_or(1, |c| c.len_utf8());
369      norm_pos += ch_len;
370      strip_pos += ch_len;
371    }
372  }
373
374  // Map stripped position back to original position
375  if strip_pos >= stripped.len() {
376    *stripped_to_orig.last().unwrap_or(&orig_pos)
377  } else {
378    // Count how many non-underscore chars we consumed
379    let stripped_chars_consumed = stripped[..strip_pos].chars().count();
380    stripped_to_orig
381      .get(stripped_chars_consumed)
382      .copied()
383      .unwrap_or(orig_pos)
384  }
385}
386
387/// Return the visible (non-ANSI) display width of a string.
388///
389/// Uses Unicode display widths so CJK characters count as 2 and emoji count as 2.
390pub fn visible_len(s: &str) -> usize {
391  unicode_width::UnicodeWidthStr::width(strip_ansi(s).as_str())
392}
393
394/// Return a sorted list of all supported color names.
395#[cfg(test)]
396fn available_colors() -> Vec<&'static str> {
397  vec![
398    "alert",
399    "bgblack",
400    "bgblue",
401    "bgcyan",
402    "bggreen",
403    "bgmagenta",
404    "bgpurple",
405    "bgred",
406    "bgwhite",
407    "bgyellow",
408    "black",
409    "blink",
410    "blue",
411    "bold",
412    "boldbgblack",
413    "boldbgblue",
414    "boldbgcyan",
415    "boldbggreen",
416    "boldbgmagenta",
417    "boldbgpurple",
418    "boldbgred",
419    "boldbgwhite",
420    "boldbgyellow",
421    "boldblack",
422    "boldblue",
423    "boldcyan",
424    "boldgreen",
425    "boldmagenta",
426    "boldpurple",
427    "boldred",
428    "boldwhite",
429    "boldyellow",
430    "chalkboard",
431    "clear",
432    "concealed",
433    "cyan",
434    "dark",
435    "default",
436    "error",
437    "flamingo",
438    "green",
439    "hotpants",
440    "italic",
441    "knightrider",
442    "led",
443    "magenta",
444    "negative",
445    "purple",
446    "rapidblink",
447    "red",
448    "redacted",
449    "reset",
450    "softpurple",
451    "strike",
452    "strikethrough",
453    "underline",
454    "underscore",
455    "white",
456    "whiteboard",
457    "yeller",
458    "yellow",
459  ]
460}
461
462fn parse_hex(s: &str) -> Option<Color> {
463  let (background, hex_str) = if let Some(rest) = s.strip_prefix("bg#").or_else(|| s.strip_prefix("b#")) {
464    (true, rest)
465  } else if let Some(rest) = s.strip_prefix("fg#").or_else(|| s.strip_prefix("f#")) {
466    (false, rest)
467  } else if let Some(rest) = s.strip_prefix('#') {
468    (false, rest)
469  } else {
470    return None;
471  };
472
473  if hex_str.len() != 6 || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
474    return None;
475  }
476
477  let r = u8::from_str_radix(&hex_str[0..2], 16).ok()?;
478  let g = u8::from_str_radix(&hex_str[2..4], 16).ok()?;
479  let b = u8::from_str_radix(&hex_str[4..6], 16).ok()?;
480
481  Some(Color::Hex {
482    background,
483    b,
484    g,
485    r,
486  })
487}
488
489#[cfg(test)]
490mod test {
491  use super::*;
492
493  mod available_colors {
494    use super::*;
495
496    #[test]
497    fn it_contains_basic_colors() {
498      let colors = available_colors();
499
500      assert!(colors.contains(&"red"));
501      assert!(colors.contains(&"green"));
502      assert!(colors.contains(&"blue"));
503      assert!(colors.contains(&"cyan"));
504      assert!(colors.contains(&"yellow"));
505      assert!(colors.contains(&"magenta"));
506      assert!(colors.contains(&"white"));
507      assert!(colors.contains(&"black"));
508    }
509
510    #[test]
511    fn it_contains_reset() {
512      let colors = available_colors();
513
514      assert!(colors.contains(&"reset"));
515      assert!(colors.contains(&"default"));
516    }
517
518    #[test]
519    fn it_returns_sorted_list() {
520      let colors = available_colors();
521
522      let mut sorted = colors.clone();
523      sorted.sort();
524      assert_eq!(colors, sorted);
525    }
526  }
527
528  mod color_parse {
529    use super::*;
530
531    #[test]
532    fn it_normalizes_bright_to_bold() {
533      let color = Color::parse("brightwhite");
534
535      assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
536    }
537
538    #[test]
539    fn it_normalizes_underscores() {
540      let color = Color::parse("bold_white");
541
542      assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
543    }
544
545    #[test]
546    fn it_parses_bold_color() {
547      let color = Color::parse("boldwhite");
548
549      assert_eq!(color, Some(Color::Named(NamedColor::BoldWhite)));
550    }
551
552    #[test]
553    fn it_parses_hex_background() {
554      let color = Color::parse("bg#00FF00");
555
556      assert_eq!(
557        color,
558        Some(Color::Hex {
559          background: true,
560          b: 0x00,
561          g: 0xFF,
562          r: 0x00,
563        })
564      );
565    }
566
567    #[test]
568    fn it_parses_hex_foreground() {
569      let color = Color::parse("#FF5500");
570
571      assert_eq!(
572        color,
573        Some(Color::Hex {
574          background: false,
575          b: 0x00,
576          g: 0x55,
577          r: 0xFF,
578        })
579      );
580    }
581
582    #[test]
583    fn it_parses_named_color() {
584      let color = Color::parse("cyan");
585
586      assert_eq!(color, Some(Color::Named(NamedColor::Cyan)));
587    }
588
589    #[test]
590    fn it_returns_none_for_invalid() {
591      assert_eq!(Color::parse("notacolor"), None);
592    }
593  }
594
595  mod color_to_ansi {
596    use super::*;
597
598    #[test]
599    fn it_emits_empty_when_disabled() {
600      yansi::disable();
601
602      let result = Color::Named(NamedColor::Cyan).to_ansi();
603
604      assert_eq!(result, "");
605      yansi::enable();
606    }
607
608    #[test]
609    fn it_emits_hex_background() {
610      yansi::enable();
611      let color = Color::Hex {
612        background: true,
613        b: 0x00,
614        g: 0xFF,
615        r: 0x00,
616      };
617
618      let result = color.to_ansi();
619
620      assert!(result.contains("48;2;0;255;0"), "expected RGB bg escape, got: {result}");
621    }
622
623    #[test]
624    fn it_emits_hex_foreground() {
625      yansi::enable();
626      let color = Color::Hex {
627        background: false,
628        b: 0x00,
629        g: 0x55,
630        r: 0xFF,
631      };
632
633      let result = color.to_ansi();
634
635      assert!(
636        result.contains("38;2;255;85;0"),
637        "expected RGB fg escape, got: {result}"
638      );
639    }
640
641    #[test]
642    fn it_emits_named_ansi() {
643      yansi::enable();
644
645      let result = Color::Named(NamedColor::Cyan).to_ansi();
646
647      assert!(result.contains("36"), "expected cyan code 36, got: {result}");
648    }
649
650    #[test]
651    fn it_emits_reset() {
652      yansi::enable();
653
654      let result = Color::Named(NamedColor::Reset).to_ansi();
655
656      assert_eq!(result, "\x1b[0m");
657    }
658  }
659
660  mod strip_ansi {
661    use pretty_assertions::assert_eq;
662
663    use super::super::strip_ansi;
664
665    #[test]
666    fn it_removes_escape_sequences() {
667      let input = "\x1b[36mhello\x1b[0m world";
668
669      assert_eq!(strip_ansi(input), "hello world");
670    }
671
672    #[test]
673    fn it_returns_plain_text_unchanged() {
674      assert_eq!(strip_ansi("hello"), "hello");
675    }
676
677    #[test]
678    fn it_strips_cursor_movement_sequences() {
679      let input = "\x1b[2Ahello\x1b[3Bworld";
680
681      assert_eq!(strip_ansi(input), "helloworld");
682    }
683
684    #[test]
685    fn it_strips_erase_sequences() {
686      let input = "hello\x1b[2Jworld";
687
688      assert_eq!(strip_ansi(input), "helloworld");
689    }
690
691    #[test]
692    fn it_strips_scroll_sequences() {
693      let input = "hello\x1b[1Sworld";
694
695      assert_eq!(strip_ansi(input), "helloworld");
696    }
697  }
698
699  mod validate_color {
700    use pretty_assertions::assert_eq;
701
702    use super::super::validate_color;
703
704    #[test]
705    fn it_finds_longest_prefix() {
706      assert_eq!(validate_color("boldbluefoo"), Some(("boldblue".into(), 8)));
707    }
708
709    #[test]
710    fn it_returns_correct_original_length_with_underscores() {
711      assert_eq!(validate_color("bold_white"), Some(("boldwhite".into(), 10)));
712    }
713
714    #[test]
715    fn it_returns_none_for_invalid() {
716      assert_eq!(validate_color("notacolor"), None);
717    }
718
719    #[test]
720    fn it_validates_bg_hex() {
721      assert_eq!(validate_color("bg#00FF00"), Some(("bg#00FF00".into(), 9)));
722    }
723
724    #[test]
725    fn it_validates_bold_color() {
726      assert_eq!(validate_color("boldwhite"), Some(("boldwhite".into(), 9)));
727    }
728
729    #[test]
730    fn it_validates_bright_red_with_underscore() {
731      assert_eq!(validate_color("bright_red"), Some(("boldred".into(), 10)));
732    }
733
734    #[test]
735    fn it_validates_hex() {
736      assert_eq!(validate_color("#FF5500"), Some(("#FF5500".into(), 7)));
737    }
738
739    #[test]
740    fn it_validates_simple_color() {
741      assert_eq!(validate_color("cyan"), Some(("cyan".into(), 4)));
742    }
743  }
744
745  mod visible_len {
746    use pretty_assertions::assert_eq;
747
748    use super::super::visible_len;
749
750    #[test]
751    fn it_counts_plain_text() {
752      assert_eq!(visible_len("hello"), 5);
753    }
754
755    #[test]
756    fn it_counts_cjk_characters_as_double_width() {
757      // CJK characters are 2 display columns each
758      assert_eq!(visible_len("日本語"), 6);
759    }
760
761    #[test]
762    fn it_counts_emoji_as_double_width() {
763      // Most emoji are 2 display columns
764      assert_eq!(visible_len("🎉"), 2);
765    }
766
767    #[test]
768    fn it_excludes_ansi_codes() {
769      let input = "\x1b[36mhello\x1b[0m";
770
771      assert_eq!(visible_len(input), 5);
772    }
773  }
774}