use crate::IntoText as _;
use pretty_assertions::assert_eq;
use ratatui_core::{
style::{Color, Style, Stylize},
text::{Line, Span, Text},
};
#[test]
fn parses_plain_text_without_styles() {
let string: Vec<u8> = "FOO".to_string().bytes().collect();
test_both(string, Text::raw("FOO"));
}
#[test]
fn parses_unicode_text() {
// These are 8 byte unicode characters.
// First 4 bytes are for the unicode and the last 4 bytes are for the color / variant.
let bytes = "AAA🅱️🅱️🅱️".as_bytes().to_vec();
let output = Text::raw("AAA🅱️🅱️🅱️");
test_both(bytes, output);
}
#[test]
fn preserves_empty_lines_when_splitting_on_newlines() {
let bytes = "LINE_1\n\n\n\n\n\n\nLINE_8".as_bytes().to_vec();
let output = Text::from(vec![
Line::from("LINE_1"),
Line::from(""),
Line::from(""),
Line::from(""),
Line::from(""),
Line::from(""),
Line::from(""),
Line::from("LINE_8"),
]);
test_both(bytes, output);
}
#[test]
fn mixed_cr_and_lf_sequences_are_all_newlines() {
let bytes = "A\r\n\rB\n\nC\r\r\nD".as_bytes().to_vec();
let output = Text::from(vec![
Line::from("A"),
Line::from(""),
Line::from("B"),
Line::from(""),
Line::from("C"),
Line::from(""),
Line::from("D"),
]);
test_both(bytes, output);
}
#[test]
/// Treat `\r\n` as a single newline (CRLF).
///
/// This normalizes Windows line endings so the resulting `Text` is stable across platforms.
fn treats_crlf_as_single_newline() {
let bytes = "LINE_1\r\nLINE_2\r\nLINE_3".as_bytes().to_vec();
let output = Text::from(vec![
Line::from("LINE_1"),
Line::from("LINE_2"),
Line::from("LINE_3"),
]);
test_both(bytes, output);
}
#[test]
/// Treat bare `\r` as a newline.
///
/// This avoids embedding carriage returns into spans, and makes the output consistent with LF and
/// CRLF inputs.
fn treats_bare_cr_as_newline() {
let bytes = "ABC\rDEF".as_bytes().to_vec();
let output = Text::from(vec![Line::from("ABC"), Line::from("DEF")]);
test_both(bytes, output);
}
#[test]
/// Normalize mixed LF and CRLF into consistent line boundaries.
fn mixed_lf_and_crlf_line_endings_are_normalized() {
let bytes = "A\nB\r\nC\nD\r\nE".as_bytes().to_vec();
let output = Text::from(vec![
Line::from("A"),
Line::from("B"),
Line::from("C"),
Line::from("D"),
Line::from("E"),
]);
test_both(bytes, output);
}
#[test]
/// A CRLF-only input is a single empty line.
fn crlf_only_input_is_empty_line() {
let bytes = "\r\n".as_bytes().to_vec();
let output = Text::raw("");
test_both(bytes, output);
}
#[test]
/// `\r` acts as a newline, even before non-SGR escape sequences.
///
/// This crate intentionally does not implement cursor movement/erase semantics; it only produces
/// styled text lines.
fn cr_before_non_sgr_escape_sequence_starts_new_line() {
let bytes: Vec<u8> = b"\r\x1b[KOVERWRITE".to_vec();
let output = Text::from(vec![Line::from(""), Line::from("OVERWRITE")]);
test_both(bytes, output);
}
#[test]
/// CRLF ends the line and style continues on the next line.
fn crlf_splits_lines_and_carries_style_across_lines() {
let bytes: Vec<u8> = b"A\x1b[31mB\r\nC".to_vec();
let output = Text::from(vec![
Line::from(vec![Span::raw("A"), "B".red()]),
Line::from("C".red()),
]);
test_both(bytes, output);
}
#[test]
fn ignores_truncated_escape_sequence() {
let bytes = b"\x1b[";
let output = Text::raw("");
test_both(bytes, output);
}
#[test]
fn ignores_garbage_escape_sequences() {
let bytes: Vec<u8> = b"\x1b\x1b[0\x1b[m\x1b".to_vec();
let output = Text::raw("");
test_both(bytes, output);
}
#[test]
fn ignores_non_sgr_escape_sequences() {
let bytes: Vec<u8> = b"\x1b[?25hAAABBB".to_vec();
let output = Text::raw("AAABBB");
test_both(bytes, output);
}
#[test]
fn ignores_osc_and_other_non_sgr_sequences() {
// Malformed -> malformed -> empty
let bytes = b"\x1b[4 q\x1b]12;#fab1ed\x07";
let output = Text::raw("");
test_both(bytes, output);
}
#[test]
fn unknown_sgr_codes_are_ignored_and_chained_items_still_apply() {
let bytes: Vec<u8> = b"\x1b[200;31mred".to_vec();
let output = Text::from("red".red());
test_both(bytes, output);
}
#[test]
fn empty_sgr_sequence_is_treated_as_reset() {
let string = b"\x1b[32mGREEN\x1b[mFOO\nFOO";
let output = Text::from(vec![
Line::from(vec!["GREEN".green(), Span::styled("FOO", Style::reset())]),
Line::from(Span::styled("FOO", Style::reset())),
]);
test_both(string, output);
}
#[test]
fn chained_sgr_items_in_single_escape_sequence_are_applied_in_order() {
let bytes: Vec<u8> = b"\x1b[31;44;1mX".to_vec();
let output = Text::from("X".red().on_blue().bold());
test_both(bytes, output);
}
#[test]
fn does_not_emit_empty_spans_for_style_only_changes() {
// Yellow -> Red -> Green -> "Hello" -> Reset -> "World"
let bytes: Vec<u8> = b"\x1b[33m\x1b[31m\x1b[32mHello\x1b[0mWorld".to_vec();
let output = Text::from(Line::from(vec![
"Hello".green(),
Span::styled("World", Style::reset()),
]));
test_both(bytes, output);
}
#[test]
fn sgr_0_resets_style() {
let string = "\x1b[33mA\x1b[0mB";
let output = Text::from(Line::from(vec![
"A".yellow(),
Span::styled("B", Style::reset()),
]));
test_both(string, output);
}
#[test]
fn sgr_1_and_22_toggle_bold() {
let bytes = "not, \x1b[1mbold\x1b[22m, not anymore".as_bytes().to_vec();
let output = Text::from(Line::from(vec![
Span::raw("not, "),
"bold".bold(),
", not anymore".not_bold().not_dim(),
]));
test_both(bytes, output);
}
#[test]
fn sgr_2_and_22_toggle_faint() {
let bytes = "not, \x1b[2mfaint\x1b[22m, not anymore".as_bytes().to_vec();
let output = Text::from(Line::from(vec![
Span::raw("not, "),
"faint".dim(),
", not anymore".not_bold().not_dim(),
]));
test_both(bytes, output);
}
#[test]
fn sgr_3_and_23_toggle_italic() {
let bytes = "not, \x1b[3mitalic\x1b[23m, not anymore"
.as_bytes()
.to_vec();
let output = Text::from(Line::from(vec![
Span::raw("not, "),
"italic".italic(),
", not anymore".not_italic(),
]));
test_both(bytes, output);
}
#[test]
fn sgr_4_and_24_toggle_underline() {
let bytes = "not, \x1b[4munderlined\x1b[24m, not anymore"
.as_bytes()
.to_vec();
let output = Text::from(Line::from(vec![
Span::raw("not, "),
"underlined".underlined(),
", not anymore".not_underlined(),
]));
test_both(bytes, output);
}
#[test]
fn sgr_5_and_25_toggle_slow_blink() {
let bytes = "not, \x1b[5mblinking\x1b[25m, not anymore"
.as_bytes()
.to_vec();
let output = Text::from(Line::from(vec![
Span::raw("not, "),
"blinking".slow_blink(),
", not anymore".not_slow_blink().not_rapid_blink(),
]));
test_both(bytes, output);
}
#[test]
fn sgr_6_and_25_toggle_rapid_blink() {
let bytes = "not, \x1b[6mrapid\x1b[25m, not anymore".as_bytes().to_vec();
let output = Text::from(Line::from(vec![
Span::raw("not, "),
"rapid".rapid_blink(),
", not anymore".not_slow_blink().not_rapid_blink(),
]));
test_both(bytes, output);
}
#[test]
fn sgr_7_and_27_toggle_reverse_video() {
let bytes = "not, \x1b[7mreversed\x1b[27m, not anymore"
.as_bytes()
.to_vec();
let output = Text::from(Line::from(vec![
Span::raw("not, "),
"reversed".reversed(),
", not anymore".not_reversed(),
]));
test_both(bytes, output);
}
#[test]
fn sgr_8_and_28_toggle_conceal() {
let bytes = "not, \x1b[8mconcealed\x1b[28m, not anymore"
.as_bytes()
.to_vec();
let output = Text::from(Line::from(vec![
Span::raw("not, "),
"concealed".hidden(),
", not anymore".not_hidden(),
]));
test_both(bytes, output);
}
#[test]
fn sgr_9_and_29_toggle_crossed_out() {
let bytes = "not, \x1b[9mcrossed\x1b[29m, not anymore"
.as_bytes()
.to_vec();
let output = Text::from(Line::from(vec![
Span::raw("not, "),
"crossed".crossed_out(),
", not anymore".not_crossed_out(),
]));
test_both(bytes, output);
}
#[test]
fn parses_4bit_named_colors_and_backgrounds() {
const BLACK: &str = "\x1b[30m";
const RED: &str = "\x1b[31m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const BLUE: &str = "\x1b[34m";
const MAGENTA: &str = "\x1b[35m";
const CYAN: &str = "\x1b[36m";
const GRAY: &str = "\x1b[37m";
const BLACK_BG: &str = "\x1b[40m";
const RED_BG: &str = "\x1b[41m";
const GREEN_BG: &str = "\x1b[42m";
const YELLOW_BG: &str = "\x1b[43m";
const BLUE_BG: &str = "\x1b[44m";
const MAGENTA_BG: &str = "\x1b[45m";
const CYAN_BG: &str = "\x1b[46m";
const GRAY_BG: &str = "\x1b[47m";
let bytes = format!(
"{BLACK}black\n\
{RED}red\n\
{GREEN}green\n\
{YELLOW}yellow\n\
{BLUE}blue\n\
{MAGENTA}magenta\n\
{CYAN}cyan\n\
{GRAY}gray\n\
{BLACK}{BLACK_BG}black-bg\n\
{RED_BG}red-bg\n\
{GREEN_BG}green-bg\n\
{YELLOW_BG}yellow-bg\n\
{BLUE_BG}blue-bg\n\
{MAGENTA_BG}magenta-bg\n\
{CYAN_BG}cyan-bg\n\
{GRAY_BG}gray-bg"
)
.into_bytes();
let output = Text::from(vec![
Line::from("black".black()),
Line::from("red".red()),
Line::from("green".green()),
Line::from("yellow".yellow()),
Line::from("blue".blue()),
Line::from("magenta".magenta()),
Line::from("cyan".cyan()),
Line::from("gray".gray()),
Line::from("black-bg".black().on_black()),
Line::from("red-bg".black().on_red()),
Line::from("green-bg".black().on_green()),
Line::from("yellow-bg".black().on_yellow()),
Line::from("blue-bg".black().on_blue()),
Line::from("magenta-bg".black().on_magenta()),
Line::from("cyan-bg".black().on_cyan()),
Line::from("gray-bg".black().on_gray()),
]);
test_both(bytes, output);
}
#[test]
fn parses_4bit_bright_colors_and_backgrounds() {
const DARK_GRAY: &str = "\x1b[90m";
const LIGHT_RED: &str = "\x1b[91m";
const LIGHT_GREEN: &str = "\x1b[92m";
const LIGHT_YELLOW: &str = "\x1b[93m";
const LIGHT_BLUE: &str = "\x1b[94m";
const LIGHT_MAGENTA: &str = "\x1b[95m";
const LIGHT_CYAN: &str = "\x1b[96m";
const WHITE: &str = "\x1b[97m";
const DARK_GRAY_BG: &str = "\x1b[100m";
const LIGHT_RED_BG: &str = "\x1b[101m";
const LIGHT_GREEN_BG: &str = "\x1b[102m";
const LIGHT_YELLOW_BG: &str = "\x1b[103m";
const LIGHT_BLUE_BG: &str = "\x1b[104m";
const LIGHT_MAGENTA_BG: &str = "\x1b[105m";
const LIGHT_CYAN_BG: &str = "\x1b[106m";
const WHITE_BG: &str = "\x1b[107m";
let bytes = format!(
"{DARK_GRAY}dark-gray\n\
{LIGHT_RED}light-red\n\
{LIGHT_GREEN}light-green\n\
{LIGHT_YELLOW}light-yellow\n\
{LIGHT_BLUE}light-blue\n\
{LIGHT_MAGENTA}light-magenta\n\
{LIGHT_CYAN}light-cyan\n\
{WHITE}white\n\
\x1b[30m{DARK_GRAY_BG}dark-gray-bg\n\
{LIGHT_RED_BG}light-red-bg\n\
{LIGHT_GREEN_BG}light-green-bg\n\
{LIGHT_YELLOW_BG}light-yellow-bg\n\
{LIGHT_BLUE_BG}light-blue-bg\n\
{LIGHT_MAGENTA_BG}light-magenta-bg\n\
{LIGHT_CYAN_BG}light-cyan-bg\n\
{WHITE_BG}white-bg"
)
.into_bytes();
let output = Text::from(vec![
Line::from("dark-gray".dark_gray()),
Line::from("light-red".light_red()),
Line::from("light-green".light_green()),
Line::from("light-yellow".light_yellow()),
Line::from("light-blue".light_blue()),
Line::from("light-magenta".light_magenta()),
Line::from("light-cyan".light_cyan()),
Line::from("white".white()),
Line::from("dark-gray-bg".black().on_dark_gray()),
Line::from("light-red-bg".black().on_light_red()),
Line::from("light-green-bg".black().on_light_green()),
Line::from("light-yellow-bg".black().on_light_yellow()),
Line::from("light-blue-bg".black().on_light_blue()),
Line::from("light-magenta-bg".black().on_light_magenta()),
Line::from("light-cyan-bg".black().on_light_cyan()),
Line::from("white-bg".black().on_white()),
]);
test_both(bytes, output);
}
#[test]
fn sgr_31_and_39_toggle_foreground_color() {
let bytes: Vec<u8> = b"\x1b[31;1mred\x1b[39mdefault".to_vec();
let output = Text::from(Line::from(vec![
"red".red().bold(),
"default".bold().fg(Color::Reset),
]));
test_both(bytes, output);
}
#[test]
fn sgr_44_and_49_toggle_background_color() {
let bytes: Vec<u8> = b"\x1b[44;1mblue-bg\x1b[49mdefault".to_vec();
let output = Text::from(Line::from(vec![
"blue-bg".on_blue().bold(),
"default".bold().bg(Color::Reset),
]));
test_both(bytes, output);
}
#[test]
fn parses_256color_foreground_palette() {
for i in 0..256 {
let bytes = format!("\x1b[38;5;{}mHELLO", i).as_bytes().to_vec();
let output = Text::from("HELLO".fg(Color::Indexed(i as u8)));
test_both(bytes, output);
}
}
#[test]
fn parses_256color_background_palette() {
for i in 0..256 {
let bytes = format!("\x1b[48;5;{}mHELLO", i).as_bytes().to_vec();
let output = Text::from("HELLO".bg(Color::Indexed(i as u8)));
test_both(bytes, output);
}
}
#[test]
fn parses_truecolor_foreground() {
let bytes: Vec<u8> = b"\x1b[38;2;100;100;100mAAABBB".to_vec();
let output = Text::from("AAABBB".fg(Color::Rgb(100, 100, 100)));
test_both(bytes, output);
}
#[test]
fn parses_truecolor_foreground_and_background() {
let test_cases = [
((1, 2, 3), (4, 5, 6)),
((255, 0, 128), (0, 64, 255)),
((17, 34, 51), (68, 85, 102)),
];
for ((fr, fg, fb), (br, bg, bb)) in test_cases {
let bytes = format!("\x1b[38;2;{fr};{fg};{fb};48;2;{br};{bg};{bb}mHELLO")
.as_bytes()
.to_vec();
let output = Text::from(
"HELLO"
.fg(Color::Rgb(fr, fg, fb))
.bg(Color::Rgb(br, bg, bb)),
);
test_both(bytes, output);
}
}
#[test]
fn carries_style_across_lines_and_handles_resets() {
let bytes: Vec<u8> = String::from(
"\u{1b}[32m* \u{1b}[0mRunning before-startup command \u{1b}[1mcommand\u{1b}[0m=make my-simple-package.cabal\n\
\u{1b}[32m* \u{1b}[0m$ make my-simple-package.cabal\n\
Build profile: -w ghc-9.0.2 -O1\n",
)
.into_bytes();
let output = Text::from(vec![
Line::from(vec![
"* ".green(),
Span::styled("Running before-startup command ", Style::reset()),
Span::styled("command", Style::reset()).bold(),
Span::styled("=make my-simple-package.cabal", Style::reset()),
]),
Line::from(vec![
Span::styled("* ", Style::reset()).green(),
Span::styled("$ make my-simple-package.cabal", Style::reset()),
]),
Line::from(vec![Span::styled(
"Build profile: -w ghc-9.0.2 -O1",
Style::reset(),
)]),
]);
test_both(bytes, output);
}
#[track_caller]
fn test_both(bytes: impl AsRef<[u8]>, other: Text) {
let bytes = bytes.as_ref();
#[cfg(feature = "zero-copy")]
let zero_copy = bytes.to_text().unwrap();
let owned = bytes.into_text().unwrap();
#[cfg(feature = "zero-copy")]
assert_eq!(
zero_copy, owned,
"zero-copy and owned version of the methods have diverged; this is a bug in the library"
);
assert_eq!(
owned, other,
"owned and other have diverged; this might be a bug in the library or a ratatui update"
);
#[cfg(feature = "zero-copy")]
assert_eq!(zero_copy, other);
}