use std::borrow::Cow;
use unicode_segmentation::UnicodeSegmentation;
use super::ansi::{Esc, caret_notation, caret_width, display_width, escape_display, parse_escape};
use super::sgr::SgrState;
const TAB_WIDTH: usize = 8;
pub(super) fn cluster_width_at(cluster: &str, col: usize) -> usize {
let c = cluster.chars().next().unwrap_or('\u{fffd}');
match c {
'\t' => TAB_WIDTH - col % TAB_WIDTH,
c if c.is_control() => {
if caret_width(c) {
2
} else {
1
}
}
_ => cluster_visual_width(cluster),
}
}
pub(crate) fn wrap_line_ansi(line: &str, width: usize, start_col: usize) -> Vec<String> {
let width = width.max(1);
let bytes = line.as_bytes();
let mut chunks = Vec::new();
let mut current_chunk = String::new();
let mut visible_width = 0;
let mut style = SgrState::default();
let mut i = 0;
while i < bytes.len() {
let (piece, mut piece_width, is_tab, next): (Cow<'_, str>, usize, bool, usize) =
if bytes[i] == 0x1b {
let (end, kind) = parse_escape(bytes, i);
let seq = &line[i..end];
match kind {
Esc::Sgr => {
style.apply(seq);
(Cow::Borrowed(seq), 0, false, end)
}
Esc::Osc8 => {
(Cow::Borrowed(seq), 0, false, end)
}
Esc::Visible => {
let display = escape_display(seq);
let width = display_width(&display);
(Cow::Owned(display), width, false, end)
}
}
} else {
let cluster = &line[i..].graphemes(true).next().unwrap_or_default();
let is_tab = *cluster == "\t";
let end = i + cluster.len();
let c = cluster.chars().next().unwrap_or('\u{fffd}');
let replacement: Cow<'_, str> = match c {
'\t' => Cow::Borrowed(cluster),
c if c.is_control() => Cow::Owned(caret_notation(c)),
_ => Cow::Borrowed(cluster),
};
let ch_width = cluster_width_at(cluster, start_col + visible_width);
(replacement, ch_width, is_tab, end)
};
i = next;
if visible_width + piece_width > width && !current_chunk.is_empty() {
let prefix = style.to_ansi();
if prefix.is_some() {
current_chunk.push_str("\x1b[0m");
}
chunks.push(current_chunk);
current_chunk = String::new();
visible_width = 0;
if let Some(prefix) = prefix {
current_chunk.push_str(&prefix);
}
if is_tab {
piece_width = TAB_WIDTH - (start_col + visible_width) % TAB_WIDTH;
}
}
if piece_width > width && is_tab {
current_chunk.push_str(&" ".repeat(width));
visible_width = width;
} else {
current_chunk.push_str(&piece);
visible_width += piece_width;
}
}
if !current_chunk.is_empty() {
if style.to_ansi().is_some() {
current_chunk.push_str("\x1b[0m");
}
chunks.push(current_chunk);
}
if chunks.is_empty() {
chunks.push(String::new());
}
chunks
}
pub(super) fn cluster_visual_width(cluster: &str) -> usize {
let width = display_width(cluster);
if cluster.contains('\u{fe0f}') || cluster.contains('\u{20e3}') {
width.max(2)
} else {
width
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn csi_ending_in_multibyte_keeps_the_char() {
assert_eq!(wrap_line_ansi("\x1b[中", 20, 0), vec!["^[[中"]);
assert_eq!(wrap_line_ansi("\x1b[31中x", 20, 0), vec!["^[[31中x"]);
}
#[test]
fn adversarial_inputs_do_not_panic() {
let inputs = [
"\x1b[🎉",
"\x1b[中x",
"a\x1b[🎉b",
"🎉\x1b[🎉",
"\x1b\x1b[中",
"\x1b[3;🎉",
"\x1b[;中",
"\x1b[中\x1b[0m",
];
for input in inputs {
let _ = wrap_line_ansi(input, 4, 0);
}
}
#[test]
fn grapheme_clusters_are_not_split() {
assert_eq!(
wrap_line_ansi("🎉🏽👍🇺🇸abc", 4, 0),
vec!["🎉🏽", "👍🇺🇸", "abc"]
);
assert_eq!(wrap_line_ansi("e\u{301}x", 1, 0), vec!["e\u{301}", "x"]);
assert_eq!(
wrap_line_ansi("x👨\u{200d}👩\u{200d}👧y", 4, 0),
vec!["x", "👨\u{200d}👩\u{200d}👧", "y"]
);
}
#[test]
fn keycap_sequence_counts_two_columns() {
let chunks = wrap_line_ansi("#\u{fe0f}\u{20e3}x", 2, 0);
assert_eq!(chunks, vec!["#\u{fe0f}\u{20e3}", "x"]);
}
#[test]
fn vs16_emoji_counts_two_columns() {
let chunks = wrap_line_ansi("❤\u{fe0f}x", 2, 0);
assert_eq!(chunks, vec!["❤\u{fe0f}", "x"]);
}
#[test]
fn vs15_emoji_keeps_text_width() {
let chunks = wrap_line_ansi("❤\u{fe0e}x", 2, 0);
assert_eq!(chunks, vec!["❤\u{fe0e}x"]);
}
#[test]
fn ansi_single_style_fits_one_row() {
let line = "\x1b[31mhello\x1b[0m";
let chunks = wrap_line_ansi(line, 10, 0);
assert_eq!(chunks, vec!["\x1b[31mhello\x1b[0m"]);
}
#[test]
fn ansi_wraps_without_splitting_codes() {
let line = "\x1b[31m12345\x1b[0m";
let chunks = wrap_line_ansi(line, 3, 0);
assert_eq!(chunks.len(), 2);
assert!(chunks[0].contains("\x1b[31m"));
assert!(chunks[0].contains("123"));
assert!(chunks[0].ends_with("\x1b[0m"));
assert!(chunks[1].contains("\x1b[31m"));
assert!(chunks[1].contains("45"));
assert!(chunks[1].ends_with("\x1b[0m"));
}
#[test]
fn ansi_state_cleared_by_reset() {
let line = "\x1b[31mabc\x1b[0mdefghi";
let chunks = wrap_line_ansi(line, 3, 0);
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0], "\x1b[31mabc\x1b[0m");
assert_eq!(chunks[1], "def");
assert_eq!(chunks[2], "ghi");
}
#[test]
fn empty_sgr_reset_clears_tracked_state() {
let chunks = wrap_line_ansi("\x1b[31mab\x1b[mcd\x1b[0m", 3, 0);
assert_eq!(chunks, vec!["\x1b[31mab\x1b[mc", "d\x1b[0m"]);
}
#[test]
fn attribute_off_removes_attribute_from_replay() {
let chunks = wrap_line_ansi("\x1b[1mbold\x1b[22mnormal", 4, 0);
assert_eq!(chunks, vec!["\x1b[1mbold\x1b[22m", "norm", "al"]);
}
#[test]
fn replayed_state_is_canonical_and_compact() {
let chunks = wrap_line_ansi("\x1b[1m\x1b[31mabcdef\x1b[0m", 3, 0);
assert_eq!(
chunks,
vec!["\x1b[1m\x1b[31mabc\x1b[0m", "\x1b[1;31mdef\x1b[0m"]
);
}
#[test]
fn extended_color_survives_replay() {
let chunks = wrap_line_ansi("\x1b[38;5;123mabcdef\x1b[0m", 3, 0);
assert_eq!(
chunks,
vec!["\x1b[38;5;123mabc\x1b[0m", "\x1b[38;5;123mdef\x1b[0m"]
);
}
#[test]
fn newer_foreground_replaces_older_in_replay() {
let chunks = wrap_line_ansi("\x1b[31m\x1b[32mabcdef\x1b[0m", 3, 0);
assert_eq!(
chunks,
vec!["\x1b[31m\x1b[32mabc\x1b[0m", "\x1b[32mdef\x1b[0m"]
);
}
#[test]
fn plain_text_unchanged() {
let line = "hello world";
let chunks = wrap_line_ansi(line, 5, 0);
assert_eq!(chunks, vec!["hello", " worl", "d"]);
}
#[test]
fn tab_advances_to_next_stop() {
let chunks = wrap_line_ansi("a\tb", 8, 0);
assert_eq!(chunks, vec!["a\t", "b"]);
}
#[test]
fn tab_overshoots_own_row_when_it_does_not_fit() {
let chunks = wrap_line_ansi("a\tb", 5, 0);
assert_eq!(chunks, vec!["a", " ", "b"]);
}
#[test]
fn tab_at_column_zero_advances_full_width() {
let chunks = wrap_line_ansi("\t\tx", 8, 0);
assert_eq!(chunks, vec!["\t", "\t", "x"]);
}
#[test]
fn tab_advance_accounts_for_prefix_column() {
assert_eq!(wrap_line_ansi("\tX", 5, 0), vec![" ", "X"]);
assert_eq!(wrap_line_ansi("\tX", 5, 5), vec!["\tX"]);
}
#[test]
fn mid_tab_stop_wraps_tab_followed_by_text() {
let chunks = wrap_line_ansi("abcdefgh\tx", 12, 0);
assert_eq!(chunks, vec!["abcdefgh", "\tx"]);
}
#[test]
fn tab_mid_line_wraps_after_the_stop() {
assert_eq!(wrap_line_ansi("abc\tdef", 8, 0), vec!["abc\t", "def"]);
assert_eq!(wrap_line_ansi("ab\tcde", 8, 0), vec!["ab\t", "cde"]);
}
#[test]
fn tab_at_line_end_and_following_wrap() {
assert_eq!(wrap_line_ansi("ab\tcd", 4, 0), vec!["ab", " ", "cd"]);
}
#[test]
fn styled_tab_on_narrow_viewport_never_exceeds_the_width() {
let chunks = wrap_line_ansi("\x1b[31ma\tb\x1b[0m", 5, 0);
assert_eq!(
chunks,
vec![
"\x1b[31ma\x1b[0m",
"\x1b[31m \x1b[0m",
"\x1b[31mb\x1b[0m"
]
);
}
#[test]
fn emoji_wider_than_the_viewport_stays_whole() {
assert_eq!(
wrap_line_ansi("🎉", 1, 0),
vec!["🎉"],
"a grapheme wider than the viewport is never split or dropped"
);
assert_eq!(wrap_line_ansi("🎉\t🎉", 1, 0), vec!["🎉", " ", "🎉"]);
}
#[test]
fn tab_survives_ansi_prefix_replay_on_next_row() {
let chunks = wrap_line_ansi("\x1b[31mabcd\tef\x1b[0m", 8, 0);
assert_eq!(chunks, vec!["\x1b[31mabcd\t\x1b[0m", "\x1b[31mef\x1b[0m"]);
}
#[test]
fn tab_after_wide_char_counts_remaining_stop() {
assert_eq!(wrap_line_ansi("中\tx", 10, 0), vec!["中\tx"]);
assert_eq!(wrap_line_ansi("中\tx", 8, 0), vec!["中\t", "x"]);
}
#[test]
fn control_chars_become_caret_notation() {
let chunks = wrap_line_ansi("a\x07b\x08c\x7fd", 20, 0);
assert_eq!(chunks, vec!["a^Gb^Hc^?d"]);
}
#[test]
fn c1_controls_become_caret_notation() {
assert_eq!(wrap_line_ansi("a\u{9b}2Jb\u{85}", 20, 0), vec!["a^[2Jb^E"]);
assert_eq!(
wrap_line_ansi("\u{9b}2J", 3, 0),
vec!["^[2", "J"],
"8-bit CSI caret notation must count two columns"
);
assert_eq!(
wrap_line_ansi("\u{80}\u{9f}", 20, 0),
vec!["^@^_"],
"C1 maps to the same caret range as its C0 equivalent"
);
}
#[test]
fn caret_notation_counts_two_columns() {
assert_eq!(wrap_line_ansi("ab\x07cd", 4, 0), vec!["ab^G", "cd"]);
}
#[test]
fn caret_notation_carries_ansi_state_across_wrap() {
let chunks = wrap_line_ansi("\x1b[31mab\x07cd\x1b[0m", 4, 0);
assert_eq!(chunks, vec!["\x1b[31mab^G\x1b[0m", "\x1b[31mcd\x1b[0m"]);
}
#[test]
fn sgr_passes_through_lone_control_becomes_caret() {
let chunks = wrap_line_ansi("\x1b[1mhi\x07\x1b[0m", 20, 0);
assert_eq!(chunks, vec!["\x1b[1mhi^G\x1b[0m"]);
}
#[test]
fn non_sgr_csi_is_visible_not_executed() {
assert_eq!(wrap_line_ansi("\x1b[2Aab", 20, 0), vec!["^[[2Aab"]);
assert_eq!(wrap_line_ansi("\x1b[2Jx", 20, 0), vec!["^[[2Jx"]);
}
#[test]
fn csi_with_non_alpha_final_byte_is_visible() {
assert_eq!(wrap_line_ansi("\x1b[2~ab", 20, 0), vec!["^[[2~ab"]);
}
#[test]
fn osc8_hyperlink_passes_through_whole() {
let chunks = wrap_line_ansi("\x1b]8;;https://x.dev\x07here", 8, 0);
assert_eq!(chunks, vec!["\x1b]8;;https://x.dev\x07here"]);
}
#[test]
fn osc8_never_split_across_wrap() {
let chunks = wrap_line_ansi("abcde\x1b]8;;u\x07fghij", 5, 0);
assert_eq!(chunks, vec!["abcde\x1b]8;;u\x07", "fghij"]);
}
#[test]
fn osc8_kept_with_styling_across_wrap() {
let chunks = wrap_line_ansi("\x1b[31mabc\x1b]8;;u\x07defgh", 5, 0);
assert_eq!(
chunks,
vec!["\x1b[31mabc\x1b]8;;u\x07de\x1b[0m", "\x1b[31mfgh\x1b[0m"]
);
}
#[test]
fn osc8_hyperlink_reset_link_passes_through() {
let chunks = wrap_line_ansi("\x1b]8;;u\x07go\x1b]8;;\x07", 40, 0);
assert_eq!(chunks, vec!["\x1b]8;;u\x07go\x1b]8;;\x07"]);
}
#[test]
fn unterminated_osc8_is_visible_not_raw() {
let chunks = wrap_line_ansi("\x1b]8;;https://x.dev", 40, 0);
assert_eq!(chunks, vec!["^[]8;;https://x.dev"]);
}
#[test]
fn non_osc8_osc_stays_visible() {
let chunks = wrap_line_ansi("\x1b]0;longtitle\x07body", 8, 0);
assert_eq!(chunks, vec!["^[]0;longtitle^G", "body"]);
}
#[test]
fn osc_terminated_by_st_is_visible() {
assert_eq!(
wrap_line_ansi("\x1b]0;hi\x1b\\x", 20, 0),
vec!["^[]0;hi^[\\x"]
);
}
#[test]
fn two_byte_and_lone_escapes_are_visible() {
assert_eq!(wrap_line_ansi("\x1b7abc", 20, 0), vec!["^[7abc"]);
assert_eq!(wrap_line_ansi("\x1bXab", 20, 0), vec!["^[Xab"]);
assert_eq!(wrap_line_ansi("\x1b", 20, 0), vec!["^["]);
}
#[test]
fn empty_line_returns_empty_string() {
let chunks = wrap_line_ansi("", 10, 0);
assert_eq!(chunks, vec![""]);
}
#[test]
fn cjk_chars_wrap_by_visual_width() {
let chunks = wrap_line_ansi("一二三四五六七八九十", 10, 0);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0], "一二三四五");
assert_eq!(chunks[1], "六七八九十");
}
#[test]
fn japanese_chars_wrap_by_visual_width() {
let chunks = wrap_line_ansi("ひらがなカタカナ", 8, 0);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0], "ひらがな");
assert_eq!(chunks[1], "カタカナ");
}
#[test]
fn korean_chars_wrap_by_visual_width() {
let chunks = wrap_line_ansi("한국어테스트", 8, 0);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0], "한국어테");
assert_eq!(chunks[1], "스트");
}
#[test]
fn mixed_ascii_cjk_wrap_correctly() {
let chunks = wrap_line_ansi("A中B日C", 5, 0);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0], "A中B");
assert_eq!(chunks[1], "日C");
}
#[test]
fn mixed_ascii_japanese_wrap_correctly() {
let chunks = wrap_line_ansi("A日本語B", 4, 0);
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0], "A日");
assert_eq!(chunks[1], "本語");
assert_eq!(chunks[2], "B");
}
}