bettertui_engine 0.1.1

High-performance terminal UI framework
Documentation
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;

pub fn display_width(s: &str) -> usize {
    if !s.contains('\x1b') {
        return s.width();
    }
    let mut width = 0;
    let bytes = s.as_bytes();
    let len = bytes.len();
    let mut i = 0;

    while i < len {
        if bytes[i] == 0x1b {
            i += 1;
            if i < len && bytes[i] == b'[' {
                i += 1;
                while i < len {
                    let b = bytes[i];
                    i += 1;
                    if (0x40..=0x7e).contains(&b) {
                        break;
                    }
                }
            } else if i < len && bytes[i] == b']' {
                i += 1;
                while i < len {
                    if bytes[i] == 0x07 {
                        i += 1;
                        break;
                    }
                    if bytes[i] == 0x1b && i + 1 < len && bytes[i + 1] == b'\\' {
                        i += 2;
                        break;
                    }
                    i += 1;
                }
            }
            continue;
        }

        if bytes[i] >= 0x20 && bytes[i] <= 0x7e {
            width += 1;
            i += 1;
            continue;
        }

        if let Some(c) = s[i..].chars().next() {
            width += c.width().unwrap_or(0);
            i += c.len_utf8();
        } else {
            i += 1;
        }
    }

    width
}

pub fn grapheme_width(g: &str) -> usize {
    if g.contains('\x1b') {
        return display_width(g);
    }
    g.width()
}

pub fn char_width(c: char) -> u16 {
    c.width().unwrap_or(0) as u16
}

pub fn grapheme_clusters(s: &str) -> Vec<&str> {
    s.graphemes(true).collect()
}

pub fn grapheme_count(s: &str) -> usize {
    s.graphemes(true).count()
}

pub fn display_width_to_byte_offset(s: &str, target_col: usize) -> usize {
    let mut col = 0;
    for (byte_offset, g) in s.grapheme_indices(true) {
        let w = g.width();
        if col + w > target_col {
            return byte_offset;
        }
        col += w;
    }
    s.len()
}

pub fn byte_offset_to_display_width(s: &str, byte_offset: usize) -> usize {
    let mut col = 0;
    for (bo, g) in s.grapheme_indices(true) {
        if bo >= byte_offset {
            break;
        }
        col += g.width();
    }
    col
}

pub fn truncate_to_width(s: &str, max_width: usize) -> &str {
    let mut col = 0;
    for (byte_offset, g) in s.grapheme_indices(true) {
        let w = g.width();
        if col + w > max_width {
            return &s[..byte_offset];
        }
        col += w;
    }
    s
}

pub fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
    let ellipsis = "\u{2026}";
    let ellipsis_width = ellipsis.width();
    if max_width < ellipsis_width {
        return String::new();
    }
    let available = max_width - ellipsis_width;
    if display_width(s) <= available {
        return s.to_string();
    }
    let mut col = 0;
    let mut cut = 0;
    for (byte_offset, g) in s.grapheme_indices(true) {
        let w = g.width();
        if col + w > available {
            break;
        }
        col += w;
        cut = byte_offset + g.len();
    }
    let mut result = s[..cut].to_string();
    result.push_str(ellipsis);
    result
}

pub fn is_wide_char(c: char) -> bool {
    char_width(c) == 2
}

pub fn is_emoji(c: char) -> bool {
    matches!(c as u32,
        0x231A..=0x231B | 0x23E9..=0x23F3 | 0x23F8..=0x23FA |
        0x25FD..=0x25FE | 0x2614..=0x2615 | 0x2648..=0x2653 |
        0x267F | 0x2693 | 0x26A1 | 0x26AA..=0x26AB |
        0x26BD..=0x26BE | 0x26C4..=0x26C5 | 0x26CE | 0x26D4 |
        0x26EA | 0x26F2..=0x26F3 | 0x26F5 | 0x26FA | 0x26FD |
        0x2702 | 0x2705 | 0x2708..=0x270D | 0x270F |
        0x2712 | 0x2714 | 0x2716 | 0x271D | 0x2721 |
        0x2728 | 0x2733..=0x2734 | 0x2744 | 0x2747 | 0x274C |
        0x274E | 0x2753..=0x2755 | 0x2757 | 0x2763..=0x2764 |
        0x2795..=0x2797 | 0x27A1 | 0x27B0 | 0x27BF |
        0x2934..=0x2935 | 0x2B05..=0x2B07 | 0x2B1B..=0x2B1C |
        0x2B50 | 0x2B55 | 0x3030 | 0x303D | 0x3297 | 0x3299 |
        0x1F000..=0x1FFFF
    )
}

pub fn is_nerd_font_glyph(c: char) -> bool {
    match c as u32 {
        0xE000..=0xE0FF | 0xEE00..=0xEEFF => true,
        0xF500..=0xFDFF => true,
        0xFE000..=0xFE02F => true,
        0xFE300..=0xFE4FF => true,
        0x1F600..=0x1F64F => false,
        _ => c.width().unwrap_or(0) == 2 && is_emoji(c),
    }
}

pub fn is_box_drawing(c: char) -> bool {
    matches!(c as u32, 0x2500..=0x25FF)
}

pub fn is_powerline(c: char) -> bool {
    matches!(c as u32, 0xE0A0..=0xE0D4)
}

pub fn is_zero_width(c: char) -> bool {
    c.width().unwrap_or(0) == 0
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ascii_width() {
        assert_eq!(display_width("hello"), 5);
        assert_eq!(display_width(""), 0);
    }

    #[test]
    fn ansi_width() {
        assert_eq!(display_width("\x1b[38;2;255;255;255mhello\x1b[0m"), 5);
        assert_eq!(display_width("\x1b[1m\x1b[31mWORLD\x1b[0m"), 5);
    }

    #[test]
    fn cjk_width() {
        assert_eq!(display_width("\u{4e2d}\u{6587}"), 4);
        assert_eq!(char_width('\u{4e2d}'), 2);
    }

    #[test]
    fn emoji_width() {
        assert_eq!(char_width('\u{231A}'), 2);
    }

    #[test]
    fn grapheme_emoji_sequence() {
        let s = "a\u{301}";
        let clusters = grapheme_clusters(s);
        assert_eq!(clusters.len(), 1);
        assert_eq!(clusters[0], "a\u{301}");
        assert_eq!(grapheme_width(clusters[0]), 1);
    }

    #[test]
    fn grapheme_flag_emoji() {
        let s = "\u{1F1FA}\u{1F1F8}";
        let clusters = grapheme_clusters(s);
        assert_eq!(clusters.len(), 1);
    }

    #[test]
    fn zwj_emoji() {
        let s = "\u{1F468}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}";
        let clusters = grapheme_clusters(s);
        assert_eq!(clusters.len(), 1);
    }

    #[test]
    fn display_width_to_byte_offset_works() {
        let s = "ab\u{4e2d}cd";
        assert_eq!(display_width_to_byte_offset(s, 0), 0);
        assert_eq!(display_width_to_byte_offset(s, 2), 2);
        assert_eq!(display_width_to_byte_offset(s, 4), 5);
    }

    #[test]
    fn byte_offset_to_display_width_works() {
        let s = "ab\u{4e2d}cd";
        assert_eq!(byte_offset_to_display_width(s, 0), 0);
        assert_eq!(byte_offset_to_display_width(s, 2), 2);
        assert_eq!(byte_offset_to_display_width(s, 3), 4);
    }

    #[test]
    fn truncate_to_width_ascii() {
        assert_eq!(truncate_to_width("hello world", 5), "hello");
    }

    #[test]
    fn truncate_to_width_cjk() {
        assert_eq!(truncate_to_width("\u{4e2d}\u{6587}test", 4), "\u{4e2d}\u{6587}");
    }

    #[test]
    fn truncate_with_ellipsis_ascii() {
        let r = truncate_with_ellipsis("hello world", 8);
        assert_eq!(r, "hello w\u{2026}", "got {r:?}");
    }

    #[test]
    fn truncate_with_ellipsis_fits() {
        let r = truncate_with_ellipsis("hi", 8);
        assert_eq!(r, "hi");
    }

    #[test]
    fn truncate_with_ellipsis_too_narrow() {
        let r = truncate_with_ellipsis("hi", 0);
        assert_eq!(r, "");
    }

    #[test]
    fn truncate_with_ellipsis_cjk() {
        let r = truncate_with_ellipsis("\u{4e2d}\u{6587}test", 5);
        assert_eq!(r, "\u{4e2d}\u{6587}\u{2026}", "got {r:?}");
    }

    #[test]
    fn is_wide_char_works() {
        assert!(!is_wide_char('a'));
        assert!(is_wide_char('\u{4e2d}'));
        assert!(is_wide_char('\u{231A}'));
    }

    #[test]
    fn is_box_drawing_works() {
        assert!(is_box_drawing('\u{2500}'));
        assert!(is_box_drawing('\u{2550}'));
        assert!(!is_box_drawing('a'));
    }

    #[test]
    fn is_powerline_works() {
        assert!(is_powerline('\u{E0B0}'));
        assert!(!is_powerline('a'));
    }

    #[test]
    fn is_zero_width_works() {
        assert!(is_zero_width('\u{200D}'));
        assert!(!is_zero_width('a'));
    }

    #[test]
    fn grapheme_count_works() {
        assert_eq!(grapheme_count("hello"), 5);
        assert_eq!(grapheme_count("\u{1F1FA}\u{1F1F8}"), 1);
    }

    #[test]
    fn mixed_width_truncation() {
        let s = "a\u{4e2d}b\u{6587}c";
        assert_eq!(display_width(s), 7);
        let clipped = truncate_to_width(s, 5);
        assert_eq!(display_width(clipped), 4);
    }

    #[test]
    fn ellipsis_replaces_last_char() {
        let r = truncate_with_ellipsis("\u{4e2d}\u{6587}test", 5);
        assert_eq!(r, "\u{4e2d}\u{6587}\u{2026}", "got {r:?}");
    }
}