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