use jiwa::Rgb;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug, Clone, PartialEq)]
pub enum Token {
Escape(String),
Grapheme(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Tokens {
pub tokens: Vec<Token>,
pub has_input_color: bool,
}
pub fn tokenize(input: &str) -> Tokens {
let mut tokens = Vec::new();
let mut has_input_color = false;
let bytes = input.as_bytes();
let mut i = 0;
while i < input.len() {
if bytes[i] == 0x1b && i + 1 < input.len() && bytes[i + 1] == b'[' {
let mut j = i + 2;
while j < input.len() {
let b = bytes[j];
if (0x40..=0x7e).contains(&b) {
j += 1;
break;
}
j += 1;
}
let last = bytes[j - 1];
if j > i + 2 && (0x40..=0x7e).contains(&last) {
tokens.push(Token::Escape(input[i..j].to_string()));
has_input_color = true;
i = j;
continue;
}
}
let rest = &input[i..];
if let Some(g) = rest.graphemes(true).next() {
tokens.push(Token::Grapheme(g.to_string()));
i += g.len();
} else {
break;
}
}
Tokens {
tokens,
has_input_color,
}
}
pub fn plain_text(tokens: &Tokens) -> String {
let mut out = String::new();
for t in &tokens.tokens {
if let Token::Grapheme(g) = t {
out.push_str(g);
}
}
out
}
pub fn render_frame(
tokens: &[Token],
visible_count: usize,
colors: &[Rgb],
has_input_color: bool,
) -> String {
let mut last_visible_token_idx = 0usize;
{
let mut seen = 0usize;
for (idx, t) in tokens.iter().enumerate() {
if let Token::Grapheme(_) = t {
if seen < visible_count {
last_visible_token_idx = idx + 1;
seen += 1;
} else {
break;
}
}
}
}
let mut out = String::new();
let mut grapheme_idx = 0usize;
for (idx, t) in tokens.iter().enumerate() {
if idx >= last_visible_token_idx {
break;
}
match t {
Token::Escape(seq) => {
out.push_str(seq);
}
Token::Grapheme(g) => {
if grapheme_idx < visible_count {
if !has_input_color {
if let Some(c) = colors.get(grapheme_idx) {
out.push_str(&format!("\x1b[38;2;{};{};{}m", c.0, c.1, c.2));
}
}
out.push_str(g);
grapheme_idx += 1;
}
}
}
}
out.push_str("\x1b[0m");
out
}
pub fn visible_newline_rows(tokens: &[Token], visible_count: usize) -> usize {
let mut seen = 0usize;
let mut rows = 0usize;
for t in tokens {
if let Token::Grapheme(g) = t {
if seen >= visible_count {
break;
}
if g == "\n" {
rows += 1;
}
seen += 1;
}
}
rows
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokenize_plain_text_has_no_color() {
let t = tokenize("ab\nc");
assert!(!t.has_input_color);
assert_eq!(
t.tokens,
vec![
Token::Grapheme("a".into()),
Token::Grapheme("b".into()),
Token::Grapheme("\n".into()),
Token::Grapheme("c".into()),
]
);
assert_eq!(plain_text(&t), "ab\nc");
}
#[test]
fn tokenize_grapheme_clusters() {
let t = tokenize("e\u{0301}世");
assert_eq!(
t.tokens,
vec![
Token::Grapheme("e\u{0301}".into()),
Token::Grapheme("世".into()),
]
);
}
#[test]
fn tokenize_splits_escapes_and_sets_flag() {
let t = tokenize("\x1b[31mA\x1b[0m");
assert!(t.has_input_color);
assert_eq!(
t.tokens,
vec![
Token::Escape("\x1b[31m".into()),
Token::Grapheme("A".into()),
Token::Escape("\x1b[0m".into()),
]
);
assert_eq!(plain_text(&t), "A");
}
#[test]
fn render_frame_colors_each_grapheme_when_no_input_color() {
let t = tokenize("ab");
let colors = [Rgb(10, 20, 30), Rgb(40, 50, 60)];
let frame = render_frame(&t.tokens, 1, &colors, false);
assert_eq!(frame, "\x1b[38;2;10;20;30ma\x1b[0m");
let frame = render_frame(&t.tokens, 2, &colors, false);
assert_eq!(frame, "\x1b[38;2;10;20;30ma\x1b[38;2;40;50;60mb\x1b[0m");
}
#[test]
fn render_frame_passthrough_preserves_input_escapes() {
let t = tokenize("\x1b[31mA\x1b[0m");
let frame = render_frame(&t.tokens, 1, &[], true);
assert_eq!(frame, "\x1b[31mA\x1b[0m");
}
#[test]
fn render_frame_hides_beyond_visible_count() {
let t = tokenize("abc");
let colors = [Rgb(0, 0, 0); 3];
let frame = render_frame(&t.tokens, 0, &colors, false);
assert_eq!(frame, "\x1b[0m");
}
#[test]
fn visible_newline_rows_counts_only_visible() {
let t = tokenize("a\nb\nc");
assert_eq!(visible_newline_rows(&t.tokens, 3), 1);
assert_eq!(visible_newline_rows(&t.tokens, 5), 2);
assert_eq!(visible_newline_rows(&t.tokens, 1), 0);
}
#[test]
fn tokenize_incomplete_escape_not_dropped() {
let t = tokenize("\x1b[31");
assert!(!t.has_input_color);
assert_eq!(plain_text(&t), "\x1b[31");
}
#[test]
fn tokenize_lone_esc_byte() {
assert_eq!(plain_text(&tokenize("\x1b")), "\x1b");
assert_eq!(plain_text(&tokenize("a\x1bb")), "a\x1bb");
}
#[test]
fn tokenize_consecutive_escapes() {
let t = tokenize("\x1b[1m\x1b[31mA");
assert_eq!(
t.tokens,
vec![
Token::Escape("\x1b[1m".into()),
Token::Escape("\x1b[31m".into()),
Token::Grapheme("A".into()),
]
);
}
#[test]
fn tokenize_zwj_emoji_single_grapheme() {
let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
let t = tokenize(family);
assert_eq!(t.tokens, vec![Token::Grapheme(family.into())]);
}
#[test]
fn tokenize_empty_input() {
let t = tokenize("");
assert!(t.tokens.is_empty());
assert!(!t.has_input_color);
assert_eq!(plain_text(&t), "");
}
#[test]
fn render_frame_drops_trailing_escape_after_last_visible() {
let t = tokenize("A\x1b[0mB");
let frame = render_frame(&t.tokens, 1, &[Rgb(1, 2, 3)], false);
assert_eq!(frame, "\x1b[38;2;1;2;3mA\x1b[0m");
}
#[test]
fn render_frame_emits_escape_before_following_visible() {
let t = tokenize("\x1b[31mAB");
let frame = render_frame(&t.tokens, 2, &[], true);
assert_eq!(frame, "\x1b[31mAB\x1b[0m");
}
#[test]
fn render_frame_colors_shorter_than_visible_no_panic() {
let t = tokenize("ab");
let frame = render_frame(&t.tokens, 2, &[], false);
assert_eq!(frame, "ab\x1b[0m");
}
#[test]
fn render_frame_has_input_color_suppresses_jiwa_fg() {
let t = tokenize("\x1b[31mABC");
let colors = [Rgb(10, 20, 30); 3];
let frame = render_frame(&t.tokens, 3, &colors, true);
assert!(!frame.contains("38;2"));
assert_eq!(frame, "\x1b[31mABC\x1b[0m");
}
#[test]
fn visible_newline_rows_zero_and_all() {
let t = tokenize("\n\na");
assert_eq!(visible_newline_rows(&t.tokens, 0), 0);
assert_eq!(visible_newline_rows(&t.tokens, 2), 2);
assert_eq!(visible_newline_rows(&t.tokens, 3), 2);
}
#[test]
fn plain_text_excludes_all_escapes_multi() {
let t = tokenize("\x1b[31m世\x1b[0m界");
assert_eq!(plain_text(&t), "世界");
}
}