scrin 0.1.79

A terminal UI toolkit with panes, widgets, overlays, animations, and Aisling-powered effects/loaders.
Documentation
use crate::core::buffer::{Buffer, Cell};
use crate::core::color::Color;

/// Sanitize a string for safe terminal rendering.
/// - Replaces control characters (except newline/tab) with replacement char
/// - Truncates to fit within width
/// - Handles unicode width correctly (maps wide chars to single cells)
pub fn sanitize_str(s: &str, max_width: usize) -> String {
    let mut result = String::with_capacity(s.len());
    let mut width = 0;
    for ch in s.chars() {
        let w = char_display_width(ch);
        if width + w > max_width {
            break;
        }
        if ch.is_control() && ch != '\n' && ch != '\t' {
            result.push(' ');
        } else {
            result.push(ch);
        }
        width += w;
    }
    result
}

/// Sanitize a string and render it into a buffer at position (x, y).
/// Returns the number of display columns written.
pub fn sanitize_and_set(
    buf: &mut Buffer,
    x: usize,
    y: usize,
    s: &str,
    fg: Color,
    bg: Option<Color>,
    max_width: usize,
) -> usize {
    let mut col = 0;
    for ch in s.chars() {
        if col >= max_width {
            break;
        }
        let w = char_display_width(ch);
        if ch.is_control() && ch != '\n' && ch != '\t' {
            if col + 1 <= max_width {
                buf.set(x + col, y, Cell::new(' ', fg, bg));
                col += 1;
            }
        } else if w == 1 {
            if col + 1 <= max_width {
                buf.set(x + col, y, Cell::new(ch, fg, bg));
                col += 1;
            }
        } else if w == 2 {
            if col + 2 <= max_width {
                buf.set(x + col, y, Cell::new(ch, fg, bg));
                col += 2;
            }
        } else {
            if col + 1 <= max_width {
                buf.set(x + col, y, Cell::new(ch, fg, bg));
                col += 1;
            }
        }
    }
    col
}

/// Get display width of a character.
/// Returns 0 for zero-width, 1 for normal, 2 for wide (CJK, fullwidth).
pub fn char_display_width(ch: char) -> usize {
    if ch.is_control() {
        return 0;
    }
    let cp = ch as u32;
    if cp == 0x200B || cp == 0x200C || cp == 0x200D || cp == 0xFEFF {
        return 0;
    }
    if cp >= 0x1100
        && (cp <= 0x115F
            || cp == 0x2329
            || cp == 0x232A
            || (cp >= 0x2E80 && cp <= 0x303E)
            || (cp >= 0x3040 && cp <= 0x33BF)
            || (cp >= 0x3400 && cp <= 0x4DBF)
            || (cp >= 0x4E00 && cp <= 0x9FFF)
            || (cp >= 0xA000 && cp <= 0xA4CF)
            || (cp >= 0xAC00 && cp <= 0xD7AF)
            || (cp >= 0xF900 && cp <= 0xFAFF)
            || (cp >= 0xFE30 && cp <= 0xFE6F)
            || (cp >= 0xFF01 && cp <= 0xFF60)
            || (cp >= 0xFFE0 && cp <= 0xFFE6)
            || (cp >= 0x20000 && cp <= 0x2FFFD)
            || (cp >= 0x30000 && cp <= 0x3FFFD))
    {
        return 2;
    }
    1
}

/// String display width (number of terminal columns)
pub fn str_display_width(s: &str) -> usize {
    s.chars().map(char_display_width).sum()
}

/// Truncate string to fit within `max_width` display columns.
/// Appends "..." if truncated and there's room.
pub fn truncate_str(s: &str, max_width: usize) -> String {
    let w = str_display_width(s);
    if w <= max_width {
        return s.to_string();
    }
    let ellipsis_width = 3;
    if max_width <= ellipsis_width {
        return sanitize_str(s, max_width);
    }
    let target = max_width - ellipsis_width;
    let mut result = String::new();
    let mut width = 0;
    for ch in s.chars() {
        let cw = char_display_width(ch);
        if width + cw > target {
            break;
        }
        result.push(ch);
        width += cw;
    }
    result.push_str("...");
    result
}

/// Sanitize a string for use as a block title (truncate, strip control chars).
pub fn sanitize_title(s: &str, max_width: usize) -> String {
    let cleaned: String = s.chars().filter(|c| !c.is_control()).collect();
    truncate_str(&cleaned, max_width)
}

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

    #[test]
    fn test_char_display_width_ascii() {
        assert_eq!(char_display_width('a'), 1);
        assert_eq!(char_display_width(' '), 1);
        assert_eq!(char_display_width('\n'), 0);
    }

    #[test]
    fn test_char_display_width_cjk() {
        assert_eq!(char_display_width(''), 2);
        assert_eq!(char_display_width(''), 2);
    }

    #[test]
    fn test_str_display_width() {
        assert_eq!(str_display_width("abc"), 3);
        assert_eq!(str_display_width("中a"), 3);
    }

    #[test]
    fn test_sanitize_str() {
        let s = "hello\x01\x02world";
        let result = sanitize_str(s, 20);
        assert_eq!(result, "hello  world");
    }

    #[test]
    fn test_truncate_str() {
        let result = truncate_str("hello world", 8);
        assert_eq!(result, "hello...");
    }

    #[test]
    fn test_truncate_no_op() {
        let result = truncate_str("hi", 10);
        assert_eq!(result, "hi");
    }

    #[test]
    fn test_sanitize_and_set() {
        let mut buf = Buffer::new(10, 1);
        let written = sanitize_and_set(&mut buf, 0, 0, "abc", Color::WHITE, None, 10);
        assert_eq!(written, 3);
        assert_eq!(buf.get(0, 0).unwrap().ch, 'a');
        assert_eq!(buf.get(2, 0).unwrap().ch, 'c');
    }

    #[test]
    fn test_sanitize_and_set_truncates() {
        let mut buf = Buffer::new(5, 1);
        let written = sanitize_and_set(&mut buf, 0, 0, "hello world", Color::WHITE, None, 5);
        assert_eq!(written, 5);
        assert_eq!(buf.get(0, 0).unwrap().ch, 'h');
        assert_eq!(buf.get(4, 0).unwrap().ch, 'o');
    }

    #[test]
    fn test_sanitize_title() {
        let result = sanitize_title("my\x01title", 10);
        assert_eq!(result, "mytitle");
    }
}