use std::sync::OnceLock;
use crate::cell_widths::{NARROW_TO_WIDE, TABLES, VERSIONS};
fn parse_version(version: &str) -> Option<(i64, i64, i64)> {
let mut parts = [0i64; 3];
for (index, part) in version.split('.').enumerate() {
let value: i64 = part.trim().parse().ok()?;
if index < 3 {
parts[index] = value;
}
}
Some((parts[0], parts[1], parts[2]))
}
fn resolve_version(requested: &str) -> usize {
let latest = VERSIONS.len() - 1;
let Some(wanted) = parse_version(requested) else {
return latest;
};
let shipped = || {
VERSIONS
.iter()
.map(|version| parse_version(version).expect("shipped versions parse"))
};
if let Some(index) = shipped().position(|version| version == wanted) {
return index;
}
shipped()
.position(|version| version >= wanted)
.unwrap_or(VERSIONS.len())
.saturating_sub(1)
}
fn cell_table() -> &'static [(u32, u32, u8)] {
static TABLE: OnceLock<&'static [(u32, u32, u8)]> = OnceLock::new();
TABLE.get_or_init(|| {
let requested = std::env::var("UNICODE_VERSION").unwrap_or_else(|_| "latest".to_string());
table_for(&requested)
})
}
fn table_for(requested: &str) -> &'static [(u32, u32, u8)] {
TABLES[resolve_version(requested)]
}
pub fn cell_len(text: &str) -> usize {
if !text.contains(ZERO_WIDTH_JOINER) && !text.contains(VARIATION_SELECTOR_16) {
return text.chars().map(char_cell_width).sum();
}
let chars: Vec<char> = text.chars().collect();
let mut total = 0usize;
let mut last_measured: Option<char> = None;
let mut index = 0usize;
while index < chars.len() {
let c = chars[index];
if c == ZERO_WIDTH_JOINER {
index += 1; } else if c == VARIATION_SELECTOR_16 {
if let Some(previous) = last_measured.take() {
if NARROW_TO_WIDE.contains(&previous) {
total += 1;
}
}
} else {
let width = char_cell_width(c);
if width > 0 {
last_measured = Some(c);
total += width;
}
}
index += 1;
}
total
}
const ZERO_WIDTH_JOINER: char = '\u{200d}';
const VARIATION_SELECTOR_16: char = '\u{fe0f}';
const SINGLE_CELL_RANGES: [(u32, u32); 6] = [
(0x20, 0x7E), (0xA0, 0xAC), (0xAE, 0x2FF), (0x370, 0x482), (0x2500, 0x25FC), (0x2800, 0x28FF), ];
fn is_single_cell_widths(text: &str) -> bool {
text.chars().all(|c| {
let codepoint = c as u32;
SINGLE_CELL_RANGES
.iter()
.any(|(start, end)| (*start..=*end).contains(&codepoint))
})
}
pub fn split_graphemes(text: &str) -> (Vec<(usize, usize, usize)>, usize) {
let chars: Vec<(usize, char)> = text.char_indices().collect();
let count = chars.len();
let byte_at = |index: usize| chars.get(index).map_or(text.len(), |(offset, _)| *offset);
let mut spans: Vec<(usize, usize, usize)> = Vec::new();
let mut total_width = 0usize;
let mut last_measured: Option<char> = None;
let mut index = 0usize;
while index < count {
let character = chars[index].1;
if character == ZERO_WIDTH_JOINER || character == VARIATION_SELECTOR_16 {
let Some(last) = spans.last_mut() else {
let start = byte_at(index);
index += 1;
spans.push((start, byte_at(index), 0));
continue;
};
if character == ZERO_WIDTH_JOINER {
index += if index < count - 1 { 2 } else { 1 };
last.1 = byte_at(index);
} else {
index += 1;
if last_measured.is_some_and(|previous| NARROW_TO_WIDE.contains(&previous)) {
last_measured = None;
last.2 += 1;
total_width += 1;
}
last.1 = byte_at(index);
}
continue;
}
let start = byte_at(index);
let width = char_cell_width(character);
index += 1;
if width > 0 {
last_measured = Some(character);
total_width += width;
spans.push((start, byte_at(index), width));
} else if let Some(last) = spans.last_mut() {
last.1 = byte_at(index);
} else {
spans.push((start, byte_at(index), 0));
}
}
(spans, total_width)
}
fn split_text_inner(text: &str, cell_position: usize) -> (String, String) {
if cell_position == 0 {
return (String::new(), text.to_string());
}
let (spans, cell_length) = split_graphemes(text);
if cell_length == 0 || spans.is_empty() {
return (text.to_string(), String::new());
}
let mut offset = ((cell_position as f64 / cell_length as f64) * spans.len() as f64) as usize;
offset = offset.min(spans.len());
let mut left_size: usize = spans[..offset].iter().map(|span| span.2).sum();
loop {
if left_size == cell_position {
let Some(&(split, _, _)) = spans.get(offset) else {
return (text.to_string(), String::new());
};
return (text[..split].to_string(), text[split..].to_string());
}
if left_size < cell_position {
let Some(&(start, end, cell_size)) = spans.get(offset) else {
return (text.to_string(), String::new());
};
if left_size + cell_size > cell_position {
return (format!("{} ", &text[..start]), format!(" {}", &text[end..]));
}
offset += 1;
left_size += cell_size;
} else {
let Some(&(start, end, cell_size)) =
offset.checked_sub(1).and_then(|index| spans.get(index))
else {
return (String::new(), text.to_string());
};
if left_size - cell_size < cell_position {
return (format!("{} ", &text[..start]), format!(" {}", &text[end..]));
}
offset -= 1;
left_size -= cell_size;
}
}
}
pub fn split_text(text: &str, cell_position: usize) -> (String, String) {
if is_single_cell_widths(text) {
let split = char_boundary(text, cell_position);
return (text[..split].to_string(), text[split..].to_string());
}
split_text_inner(text, cell_position)
}
fn char_boundary(text: &str, index: usize) -> usize {
text.char_indices()
.nth(index)
.map_or(text.len(), |(offset, _)| offset)
}
pub fn char_cell_width(c: char) -> usize {
width_in(cell_table(), c)
}
fn width_in(table: &[(u32, u32, u8)], c: char) -> usize {
let codepoint = c as u32;
if (codepoint > 0 && codepoint < 32) || (0x7F..0xA0).contains(&codepoint) {
return 0;
}
if codepoint > table[table.len() - 1].1 {
return 1;
}
let mut lower = 0usize;
let mut upper = table.len() - 1;
while lower <= upper {
let mid = (lower + upper) / 2;
let (start, end, width) = table[mid];
if codepoint > end {
lower = mid + 1;
} else if codepoint < start {
if mid == 0 {
break;
}
upper = mid - 1;
} else {
return width as usize;
}
}
1
}
pub fn chop_cells(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
if is_single_cell_widths(text) {
let chars: Vec<char> = text.chars().collect();
return chars
.chunks(width)
.map(|chunk| chunk.iter().collect())
.collect();
}
let (spans, _) = split_graphemes(text);
let mut lines: Vec<String> = Vec::new();
let mut line_size = 0usize;
let mut line_offset = 0usize;
for (start, _end, cell_size) in spans {
if line_size + cell_size > width {
lines.push(text[line_offset..start].to_string());
line_offset = start;
line_size = 0;
}
line_size += cell_size;
}
if line_size > 0 {
lines.push(text[line_offset..].to_string());
}
lines
}
pub fn truncate(text: &str, width: usize) -> String {
if cell_len(text) <= width {
text.to_string()
} else {
set_cell_size(text, width)
}
}
pub fn set_cell_size(text: &str, total: usize) -> String {
if is_single_cell_widths(text) {
let size = text.chars().count();
if size < total {
let mut padded = text.to_string();
padded.extend(std::iter::repeat_n(' ', total - size));
return padded;
}
return text[..char_boundary(text, total)].to_string();
}
if total == 0 {
return String::new();
}
let cell_size = cell_len(text);
if cell_size == total {
return text.to_string();
}
if cell_size < total {
let mut padded = text.to_string();
padded.extend(std::iter::repeat_n(' ', total - cell_size));
return padded;
}
split_text_inner(text, total).0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_len() {
assert_eq!(cell_len("hello"), 5);
}
#[test]
fn wide_chars_count_double() {
assert_eq!(cell_len("宽"), 2);
}
#[test]
fn set_size_pads_and_truncates() {
assert_eq!(set_cell_size("hi", 5), "hi ");
assert_eq!(set_cell_size("hello", 3), "hel");
}
#[test]
fn chop_ascii_and_wide() {
assert_eq!(chop_cells("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
assert_eq!(chop_cells("宽宽宽宽", 3), vec!["宽", "宽", "宽", "宽"]);
}
#[test]
fn chop_char_wider_than_width_emits_empty_leading_chunk() {
assert_eq!(chop_cells("宽宽", 1), vec!["", "宽", "宽"]);
assert_eq!(chop_cells("宽", 1), vec!["", "宽"]);
assert_eq!(chop_cells("a宽b", 2), vec!["a", "宽", "b"]);
}
#[test]
fn chop_cells_folds_by_grapheme_not_by_code_point() {
let hearts = "\u{2764}\u{fe0f}".repeat(20);
let chunks = chop_cells(&hearts, 30);
assert_eq!(
chunks.iter().map(|c| cell_len(c)).collect::<Vec<_>>(),
vec![30, 10],
"a VS16 run overflowed its width"
);
let family = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}".repeat(4);
assert_eq!(
chop_cells(&family, 5)
.iter()
.map(|c| cell_len(c))
.collect::<Vec<_>>(),
vec![4, 4],
"a ZWJ cluster was split"
);
}
#[test]
fn chop_cells_drops_a_trailing_zero_cell_run() {
assert_eq!(chop_cells("\n", 4), Vec::<String>::new());
}
#[test]
fn set_cell_size_swaps_a_straddled_grapheme_for_a_space() {
let hearts = "\u{2764}\u{fe0f}\u{2764}\u{fe0f}";
assert_eq!(set_cell_size(hearts, 1), " ");
assert_eq!(set_cell_size(hearts, 3), "\u{2764}\u{fe0f} ");
assert_eq!(set_cell_size("宽宽", 3), "宽 ");
assert_eq!(set_cell_size(hearts, 2), "\u{2764}\u{fe0f}");
assert_eq!(set_cell_size(hearts, 4), hearts);
}
#[test]
fn split_graphemes_clusters_joiners_and_selectors() {
let family = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}";
let text = format!("a{family}b");
assert_eq!(
split_graphemes(&text),
(vec![(0, 1, 1), (1, 26, 2), (26, 27, 1)], 4)
);
assert_eq!(
split_graphemes("\u{2764}\u{fe0f}"),
(vec![(0, 6, 2)], 2),
"heart (3 bytes) + VS16 (3 bytes) is one 2-cell grapheme"
);
}
#[test]
fn chop_keeps_combining_marks_attached() {
let decomposed: String = "abcdef".chars().flat_map(|c| [c, '\u{301}']).collect();
let chunks = chop_cells(&decomposed, 3);
assert_eq!(chunks.len(), 2);
assert_eq!(
chunks.iter().map(|c| cell_len(c)).collect::<Vec<_>>(),
vec![3, 3]
);
assert_eq!(chunks[0].chars().count(), 6);
}
#[test]
fn emoji_clusters_measure_as_one_glyph() {
for (text, expected, what) in [
("\u{2764}\u{fe0f}", 2, "heart + VS16"),
("\u{26a0}\u{fe0f}", 2, "warning + VS16"),
(
"\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}",
2,
"ZWJ family",
),
("\u{1f44d}\u{1f3fb}", 2, "thumbs up + skin tone"),
(
"\u{1f3f3}\u{fe0f}\u{200d}\u{1f308}",
2,
"rainbow flag (ZWJ)",
),
("1\u{fe0f}\u{20e3}", 2, "keycap"),
] {
assert_eq!(cell_len(text), expected, "{what} measured wrongly");
}
}
#[test]
fn unicode_version_selects_upstreams_table() {
for (requested, expected) in [
("9", "9.0.0"),
("9.0", "9.0.0"),
("9.0.0", "9.0.0"),
("9.0.0.7", "9.0.0"),
(" 9 ", "9.0.0"),
("13.1", "13.0.0"),
("12.1", "12.1.0"),
("12.1.0", "12.1.0"),
("0", "4.1.0"),
("-1", "4.1.0"),
("1.0.0", "4.1.0"),
("4.1.0", "4.1.0"),
("17.0.0", "17.0.0"),
("18.0.0", "17.0.0"),
("99", "17.0.0"),
("latest", "17.0.0"),
("auto", "17.0.0"),
("banana", "17.0.0"),
("", "17.0.0"),
] {
assert_eq!(
VERSIONS[resolve_version(requested)],
expected,
"UNICODE_VERSION={requested:?} chose the wrong table"
);
}
}
#[test]
fn an_older_table_measures_emoji_narrower() {
for (c, widths) in [
('\u{1F600}', [1, 1, 2, 2, 2]),
('\u{231A}', [1, 1, 2, 2, 2]),
('\u{1F9E0}', [1, 1, 1, 2, 2]),
('\u{1FAF0}', [1, 1, 1, 1, 2]),
] {
let measured: Vec<usize> = ["4.1.0", "8.0.0", "9.0.0", "12.0.0", "17.0.0"]
.iter()
.map(|version| width_in(table_for(version), c))
.collect();
assert_eq!(measured, widths.to_vec(), "{c:?} measured wrongly");
}
}
#[test]
fn combining_and_modifier_characters_take_no_cells() {
assert_eq!(
cell_len("\u{915}\u{93f}"),
1,
"Devanagari vowel sign should be zero-width"
);
assert_eq!(
cell_len("\u{1f3fb}"),
0,
"a lone skin-tone modifier is zero-width"
);
assert_eq!(cell_len("\u{4f60}\u{597d}"), 4, "CJK stayed wide");
assert_eq!(cell_len("ascii"), 5, "ASCII unaffected");
}
}