use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthChar;
const VS16: char = '\u{FE0F}';
const VS15: char = '\u{FE0E}';
pub fn grapheme_cols(cluster: &str) -> u16 {
let mut vs16 = false;
let mut vs15 = false;
let mut sum: u16 = 0;
for ch in cluster.chars() {
match ch {
VS16 => vs16 = true,
VS15 => vs15 = true,
_ => sum = sum.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16),
}
}
if vs16 {
return 2;
}
if vs15 {
return 1;
}
sum.min(2)
}
pub fn str_cols(s: &str) -> u16 {
s.graphemes(true)
.map(grapheme_cols)
.fold(0, u16::saturating_add)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_and_cjk_match_scalar_width() {
assert_eq!(grapheme_cols("a"), 1);
assert_eq!(grapheme_cols("世"), 2);
assert_eq!(str_cols("ab世"), 4);
}
#[test]
fn combining_marks_keep_base_width() {
assert_eq!(grapheme_cols("e\u{0301}"), 1);
}
#[test]
fn single_scalar_emoji_is_wide() {
assert_eq!(grapheme_cols("✅"), 2); assert_eq!(grapheme_cols("🚀"), 2); }
#[test]
fn variation_selectors_pick_presentation() {
assert_eq!(grapheme_cols("❤\u{FE0F}"), 2, "VS16 forces emoji (wide)");
assert_eq!(grapheme_cols("❤\u{FE0E}"), 1, "VS15 forces text (narrow)");
}
#[test]
fn zwj_flag_and_modifier_clamp_to_one_wide_cell() {
assert_eq!(grapheme_cols("👨\u{200D}👩\u{200D}👧"), 2, "ZWJ family");
assert_eq!(grapheme_cols("🇺🇸"), 2, "regional-indicator flag");
assert_eq!(grapheme_cols("👍🏽"), 2, "skin-tone modifier");
assert_eq!(grapheme_cols("1\u{FE0F}\u{20E3}"), 2, "keycap");
}
#[test]
fn str_cols_sums_emoji_run() {
assert_eq!(str_cols("👨\u{200D}👩\u{200D}👧 🇺🇸"), 5);
}
}