use regex::Regex;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::OnceLock;
static ANSI_REGEX: OnceLock<std::result::Result<Regex, regex::Error>> = OnceLock::new();
const BASIC_COLORS: &[(&str, u8)] = &[
("black", 0),
("red", 1),
("green", 2),
("yellow", 3),
("blue", 4),
("magenta", 5),
("cyan", 6),
("white", 7),
("brightblack", 8),
("brightred", 9),
("brightgreen", 10),
("brightyellow", 11),
("brightblue", 12),
("brightmagenta", 13),
("brightcyan", 14),
("brightwhite", 15),
];
fn ansi_regex() -> Option<&'static Regex> {
ANSI_REGEX.get_or_init(|| Regex::new(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")).as_ref().ok()
}
pub mod control {
pub const BEL: &str = "\x07";
pub const BS: &str = "\x08";
pub const HT: &str = "\x09";
pub const LF: &str = "\x0A";
pub const VT: &str = "\x0B";
pub const FF: &str = "\x0C";
pub const CR: &str = "\x0D";
pub const ESC: &str = "\x1B";
pub const DEL: &str = "\x7F";
}
pub mod csi {
pub const CSI: &str = "\x1B[";
pub fn cursor_up(n: usize) -> String {
format!("\x1B[{n}A")
}
pub fn cursor_down(n: usize) -> String {
format!("\x1B[{n}B")
}
pub fn cursor_forward(n: usize) -> String {
format!("\x1B[{n}C")
}
pub fn cursor_back(n: usize) -> String {
format!("\x1B[{n}D")
}
pub fn cursor_next_line(n: usize) -> String {
format!("\x1B[{n}E")
}
pub fn cursor_prev_line(n: usize) -> String {
format!("\x1B[{n}F")
}
pub fn cursor_horizontal(n: usize) -> String {
format!("\x1B[{n}G")
}
pub fn cursor_position(row: usize, col: usize) -> String {
format!("\x1B[{row};{col}H")
}
pub fn erase_in_display(n: usize) -> String {
format!("\x1B[{n}J")
}
pub fn erase_in_line(n: usize) -> String {
format!("\x1B[{n}K")
}
pub fn scroll_up(n: usize) -> String {
format!("\x1B[{n}S")
}
pub fn scroll_down(n: usize) -> String {
format!("\x1B[{n}T")
}
pub const REQUEST_CURSOR_POSITION: &str = "\x1B[6n";
pub const SAVE_CURSOR_POSITION: &str = "\x1B[s";
pub const RESTORE_CURSOR_POSITION: &str = "\x1B[u";
pub const HIDE_CURSOR: &str = "\x1B[?25l";
pub const SHOW_CURSOR: &str = "\x1B[?25h";
pub const ENABLE_ALT_SCREEN: &str = "\x1B[?1049h";
pub const DISABLE_ALT_SCREEN: &str = "\x1B[?1049l";
}
pub mod sgr {
pub const RESET: &str = "\x1B[0m";
pub const BOLD: &str = "\x1B[1m";
pub const DIM: &str = "\x1B[2m";
pub const ITALIC: &str = "\x1B[3m";
pub const UNDERLINE: &str = "\x1B[4m";
pub const BLINK: &str = "\x1B[5m";
pub const RAPID_BLINK: &str = "\x1B[6m";
pub const REVERSE: &str = "\x1B[7m";
pub const CONCEAL: &str = "\x1B[8m";
pub const STRIKE: &str = "\x1B[9m";
pub const PRIMARY_FONT: &str = "\x1B[10m";
pub fn alt_font(n: usize) -> String {
if !(1..=9).contains(&n) {
return String::new();
}
format!("\x1B[{}m", 10 + n)
}
pub const FRAKTUR: &str = "\x1B[20m";
pub const DOUBLE_UNDERLINE: &str = "\x1B[21m";
pub const NORMAL_INTENSITY: &str = "\x1B[22m";
pub const NO_ITALIC: &str = "\x1B[23m";
pub const NO_UNDERLINE: &str = "\x1B[24m";
pub const NO_BLINK: &str = "\x1B[25m";
pub const PROPORTIONAL_SPACING: &str = "\x1B[26m";
pub const NO_REVERSE: &str = "\x1B[27m";
pub const REVEAL: &str = "\x1B[28m";
pub const NO_STRIKE: &str = "\x1B[29m";
pub fn fg_color(n: usize) -> String {
format!("\x1B[{n}m")
}
pub fn bg_color(n: usize) -> String {
format!("\x1B[{n}m")
}
pub fn fg_color_256(n: u8) -> String {
format!("\x1B[38;5;{n}m")
}
pub fn bg_color_256(n: u8) -> String {
format!("\x1B[48;5;{n}m")
}
pub fn fg_color_rgb(r: u8, g: u8, b: u8) -> String {
format!("\x1B[38;2;{r};{g};{b}m")
}
pub fn bg_color_rgb(r: u8, g: u8, b: u8) -> String {
format!("\x1B[48;2;{r};{g};{b}m")
}
pub const DEFAULT_FG: &str = "\x1B[39m";
pub const DEFAULT_BG: &str = "\x1B[49m";
pub const NO_PROPORTIONAL_SPACING: &str = "\x1B[50m";
pub const FRAMED: &str = "\x1B[51m";
pub const ENCIRCLED: &str = "\x1B[52m";
pub const OVERLINED: &str = "\x1B[53m";
pub const NO_FRAMED: &str = "\x1B[54m";
pub const NO_OVERLINED: &str = "\x1B[55m";
pub const IDEOGRAM_UNDERLINE: &str = "\x1B[60m";
pub const IDEOGRAM_DOUBLE_UNDERLINE: &str = "\x1B[61m";
pub const IDEOGRAM_OVERLINE: &str = "\x1B[62m";
pub const IDEOGRAM_DOUBLE_OVERLINE: &str = "\x1B[63m";
pub const IDEOGRAM_STRESS: &str = "\x1B[64m";
pub const NO_IDEOGRAM: &str = "\x1B[65m";
pub const SUPERSCRIPT: &str = "\x1B[73m";
pub const SUBSCRIPT: &str = "\x1B[74m";
pub const NO_SCRIPT: &str = "\x1B[75m";
}
pub mod osc {
pub fn set_title(title: &str) -> String {
format!("\x1B]0;{title}\x07")
}
pub fn set_window_icon_title(title: &str) -> String {
format!("\x1B]2;{title}\x07")
}
pub fn set_icon_title(title: &str) -> String {
format!("\x1B]1;{title}\x07")
}
pub fn set_color(num: u8, rgb: &str) -> String {
format!("\x1B]4;{num};{rgb}\x07")
}
pub fn hyperlink(url: &str, text: &str) -> String {
format!("\x1B]8;;{url}\x07{text}\x1B]8;;\x07")
}
}
pub mod mouse {
pub const NORMAL_TRACKING: &str = "\x1B[?1000h";
pub const NO_NORMAL_TRACKING: &str = "\x1B[?1000l";
pub const HIGHLIGHT_TRACKING: &str = "\x1B[?1001h";
pub const NO_HIGHLIGHT_TRACKING: &str = "\x1B[?1001l";
pub const BUTTON_EVENT_TRACKING: &str = "\x1B[?1002h";
pub const NO_BUTTON_EVENT_TRACKING: &str = "\x1B[?1002l";
pub const ANY_EVENT_TRACKING: &str = "\x1B[?1003h";
pub const NO_ANY_EVENT_TRACKING: &str = "\x1B[?1003l";
pub const FOCUS_TRACKING: &str = "\x1B[?1004h";
pub const NO_FOCUS_TRACKING: &str = "\x1B[?1004l";
pub const EXTENDED_COORDINATES: &str = "\x1B[?1006h";
pub const NO_EXTENDED_COORDINATES: &str = "\x1B[?1006l";
pub const SGR_COORDINATES: &str = "\x1B[?1016h";
pub const NO_SGR_COORDINATES: &str = "\x1B[?1016l";
}
pub mod modes {
pub const APPLICATION_CURSOR_KEYS: &str = "\x1B[?1h";
pub const NORMAL_CURSOR_KEYS: &str = "\x1B[?1l";
pub const ANSI_MODE: &str = "\x1B[?2h";
pub const VT52_MODE: &str = "\x1B[?2l";
pub const MODE_132_COLUMN: &str = "\x1B[?3h";
pub const MODE_80_COLUMN: &str = "\x1B[?3l";
pub const SMOOTH_SCROLL: &str = "\x1B[?4h";
pub const JUMP_SCROLL: &str = "\x1B[?4l";
pub const REVERSE_SCREEN: &str = "\x1B[?5h";
pub const NORMAL_SCREEN: &str = "\x1B[?5l";
pub const APPLICATION_KEYPAD: &str = "\x1B[?66h";
pub const NUMERIC_KEYPAD: &str = "\x1B[?66l";
pub const WRAPAROUND: &str = "\x1B[?7h";
pub const NO_WRAPAROUND: &str = "\x1B[?7l";
pub const AUTOREPEAT_KEYS: &str = "\x1B[?8h";
pub const NO_AUTOREPEAT_KEYS: &str = "\x1B[?8l";
pub const MOUSE_TRACKING: &str = "\x1B[?9h";
pub const NO_MOUSE_TRACKING: &str = "\x1B[?9l";
pub const SHOW_TOOLBAR: &str = "\x1B[?10h";
pub const HIDE_TOOLBAR: &str = "\x1B[?10l";
pub const BLINKING_CURSOR: &str = "\x1B[?12h";
pub const NO_BLINKING_CURSOR: &str = "\x1B[?12l";
pub const PRINT_FORM_FEED: &str = "\x1B[?18h";
pub const NO_PRINT_FORM_FEED: &str = "\x1B[?18l";
pub const PRINT_SCREEN: &str = "\x1B[?19h";
pub const NO_PRINT_SCREEN: &str = "\x1B[?19l";
pub const NEWLINE_MODE: &str = "\x1B[20h";
pub const NO_NEWLINE_MODE: &str = "\x1B[20l";
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TermColor {
Basic(u8),
Color256(u8),
TrueColor {
r: u8,
g: u8,
b: u8,
},
}
impl TermColor {
pub fn fg_code(&self) -> String {
match self {
TermColor::Basic(n) if *n < 8 => format!("\x1B[{}m", 30 + n),
TermColor::Basic(n) if *n < 16 => format!("\x1B[{}m", 82 + n),
TermColor::Basic(n) | TermColor::Color256(n) => sgr::fg_color_256(*n),
TermColor::TrueColor { r, g, b } => sgr::fg_color_rgb(*r, *g, *b),
}
}
pub fn bg_code(&self) -> String {
match self {
TermColor::Basic(n) if *n < 8 => format!("\x1B[{}m", 40 + n),
TermColor::Basic(n) if *n < 16 => format!("\x1B[{}m", 92 + n),
TermColor::Basic(n) | TermColor::Color256(n) => sgr::bg_color_256(*n),
TermColor::TrueColor { r, g, b } => sgr::bg_color_rgb(*r, *g, *b),
}
}
}
pub fn parse_ansi_sequences(text: &str) -> Vec<(usize, String)> {
ansi_regex().map_or_else(Vec::new, |regex| {
regex.find_iter(text).map(|m| (m.start(), m.as_str().to_string())).collect()
})
}
pub fn color_name_to_code(name: &str) -> Option<TermColor> {
if let Some((_, code)) = BASIC_COLORS.iter().find(|(color, _)| color.eq_ignore_ascii_case(name))
{
return Some(TermColor::Basic(*code));
}
if let Ok(num) = u8::from_str(name) {
return Some(TermColor::Color256(num));
}
if name.starts_with('#') || (name.len() == 6 && name.chars().all(|c| c.is_ascii_hexdigit())) {
if let Some(color) = parse_hex_color(name) {
return Some(color);
}
}
None
}
fn parse_hex_color(hex: &str) -> Option<TermColor> {
let hex = hex.trim_start_matches('#');
match hex.len() {
3 => {
let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
let r = r * 17;
let g = g * 17;
let b = b * 17;
Some(TermColor::TrueColor { r, g, b })
}
6 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(TermColor::TrueColor { r, g, b })
}
_ => None,
}
}
pub fn format_ansi_text(
text: &str,
bold: bool,
italic: bool,
underline: bool,
fg_color: Option<&TermColor>,
bg_color: Option<&TermColor>,
) -> String {
let mut result = String::new();
if bold {
result.push_str(sgr::BOLD);
}
if italic {
result.push_str(sgr::ITALIC);
}
if underline {
result.push_str(sgr::UNDERLINE);
}
if let Some(color) = fg_color {
result.push_str(&color.fg_code());
}
if let Some(color) = bg_color {
result.push_str(&color.bg_code());
}
result.push_str(text);
result.push_str(sgr::RESET);
result
}
pub fn strip_ansi_codes(text: &str) -> String {
ansi_regex().map_or_else(|| text.to_string(), |regex| regex.replace_all(text, "").to_string())
}
pub fn extract_ansi_styles(text: &str) -> Vec<(usize, HashMap<String, String>)> {
let mut result = Vec::new();
let mut current_styles = HashMap::new();
let mut pos = 0;
for (idx, seq) in parse_ansi_sequences(text) {
if idx > pos {
result.push((pos, current_styles.clone()));
}
if seq == sgr::RESET {
current_styles.clear();
} else if seq == sgr::BOLD {
current_styles.insert("weight".to_string(), "bold".to_string());
} else if seq == sgr::ITALIC {
current_styles.insert("style".to_string(), "italic".to_string());
} else if seq == sgr::UNDERLINE {
current_styles.insert("text-decoration".to_string(), "underline".to_string());
} else if seq.starts_with("\x1B[38;") {
current_styles.insert("color".to_string(), extract_color_from_seq(&seq));
} else if seq.starts_with("\x1B[48;") {
current_styles.insert("background-color".to_string(), extract_color_from_seq(&seq));
}
pos = idx + seq.len();
}
if pos < text.len() {
result.push((pos, current_styles));
}
result
}
fn extract_color_from_seq(seq: &str) -> String {
if seq.starts_with("\x1B[38;5;") || seq.starts_with("\x1B[48;5;") {
let parts: Vec<&str> = seq.split(';').collect();
if parts.len() >= 3 {
if let Some(color_part) = parts[2].strip_suffix('m') {
return format!("color-{color_part}");
}
}
} else if seq.starts_with("\x1B[38;2;") || seq.starts_with("\x1B[48;2;") {
let parts: Vec<&str> = seq.split(';').collect();
if parts.len() >= 5 {
let r = parts[2];
let g = parts[3];
let b =
if let Some(stripped) = parts[4].strip_suffix('m') { stripped } else { parts[4] };
return format!("rgb({r},{g},{b})");
}
}
"unknown".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_term_color() {
let red = TermColor::Basic(1);
assert_eq!(red.fg_code(), "\x1B[31m");
assert_eq!(red.bg_code(), "\x1B[41m");
let color256 = TermColor::Color256(128);
assert_eq!(color256.fg_code(), "\x1B[38;5;128m");
assert_eq!(color256.bg_code(), "\x1B[48;5;128m");
let true_color = TermColor::TrueColor { r: 255, g: 128, b: 64 };
assert_eq!(true_color.fg_code(), "\x1B[38;2;255;128;64m");
assert_eq!(true_color.bg_code(), "\x1B[48;2;255;128;64m");
}
#[test]
fn test_color_name_to_code() {
assert_eq!(color_name_to_code("red"), Some(TermColor::Basic(1)));
assert_eq!(color_name_to_code("brightblue"), Some(TermColor::Basic(12)));
assert_eq!(color_name_to_code("123"), Some(TermColor::Color256(123)));
assert_eq!(
color_name_to_code("#ff00ff"),
Some(TermColor::TrueColor { r: 255, g: 0, b: 255 })
);
assert_eq!(color_name_to_code("#f0f"), Some(TermColor::TrueColor { r: 255, g: 0, b: 255 }));
}
#[test]
fn test_format_ansi_text() {
let text = format_ansi_text("Hello", true, false, true, Some(&TermColor::Basic(1)), None);
assert_eq!(text, "\x1B[1m\x1B[4m\x1B[31mHello\x1B[0m");
}
#[test]
fn test_strip_ansi_codes() {
let input = "\x1B[1m\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m";
let output = strip_ansi_codes(input);
assert_eq!(output, "Hello World");
}
#[test]
fn test_parse_ansi_sequences() {
let input = "Normal \x1B[1mBold\x1B[0m Normal";
let sequences = parse_ansi_sequences(input);
assert_eq!(sequences.len(), 2);
assert_eq!(sequences[0], (7, "\x1B[1m".to_string()));
assert_eq!(sequences[1], (15, "\x1B[0m".to_string()));
}
}