rae 0.1.11

Renderer-neutral widget, layout, sanitization, and GLSL shader primitives for Rust desktop tools.
Documentation
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SanitizePolicy {
    pub max_line_width: usize,
    pub max_lines: usize,
    pub preserve_newlines: bool,
    pub preserve_tabs: bool,
    pub tab_width: usize,
    pub strip_ansi: bool,
    pub remove_zero_width: bool,
}

impl SanitizePolicy {
    pub const UNLIMITED_WIDTH: usize = usize::MAX;
    pub const UNLIMITED_LINES: usize = usize::MAX;

    pub const fn plain_text() -> Self {
        Self {
            max_line_width: Self::UNLIMITED_WIDTH,
            max_lines: Self::UNLIMITED_LINES,
            preserve_newlines: true,
            preserve_tabs: false,
            tab_width: 4,
            strip_ansi: true,
            remove_zero_width: true,
        }
    }

    pub const fn single_line(max_line_width: usize) -> Self {
        Self {
            max_line_width,
            max_lines: 1,
            preserve_newlines: false,
            preserve_tabs: false,
            tab_width: 4,
            strip_ansi: true,
            remove_zero_width: true,
        }
    }

    pub const fn code_output(max_line_width: usize, max_lines: usize) -> Self {
        Self {
            max_line_width,
            max_lines,
            preserve_newlines: true,
            preserve_tabs: false,
            tab_width: 4,
            strip_ansi: true,
            remove_zero_width: true,
        }
    }
}

impl Default for SanitizePolicy {
    fn default() -> Self {
        Self::plain_text()
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SanitizedText {
    pub text: String,
    pub truncated: bool,
    pub removed_control_chars: usize,
    pub removed_escape_sequences: usize,
    pub removed_zero_width_chars: usize,
    pub display_width: usize,
    pub line_count: usize,
}

#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AnsiStripResult {
    pub text: String,
    pub removed_escape_sequences: usize,
}

pub fn sanitize_str(input: &str, max_width: usize) -> String {
    sanitize_with_policy(input, SanitizePolicy::single_line(max_width)).text
}

pub fn sanitize_title(input: &str, max_width: usize) -> String {
    let stripped = strip_ansi_escapes(input).text;
    let cleaned = stripped
        .chars()
        .filter(|ch| !ch.is_control() && !is_zero_width(*ch))
        .collect::<String>();
    truncate_str(cleaned.trim(), max_width)
}

pub fn truncate_str(input: &str, max_width: usize) -> String {
    let input = sanitize_with_policy(input, SanitizePolicy::plain_text()).text;
    if str_display_width(&input) <= max_width {
        return input;
    }
    if max_width <= 3 {
        return take_display_width(&input, max_width);
    }
    format!("{}...", take_display_width(&input, max_width - 3))
}

pub fn copy_safe_text(input: &str) -> String {
    sanitize_with_policy(input, SanitizePolicy::plain_text()).text
}

pub fn sanitize_with_policy(input: &str, policy: SanitizePolicy) -> SanitizedText {
    let stripped = if policy.strip_ansi {
        strip_ansi_escapes(input)
    } else {
        AnsiStripResult {
            text: input.to_string(),
            removed_escape_sequences: 0,
        }
    };

    let mut text = String::with_capacity(stripped.text.len());
    let mut truncated = false;
    let mut removed_control_chars = 0usize;
    let mut removed_zero_width_chars = 0usize;
    let mut line_width = 0usize;
    let mut max_display_width = 0usize;
    let mut line_count = 1usize;

    for ch in stripped.text.chars() {
        if ch == '\r' {
            removed_control_chars += 1;
            continue;
        }
        if ch == '\n' {
            if !policy.preserve_newlines {
                removed_control_chars += 1;
                push_char_with_width(
                    &mut text,
                    ' ',
                    &mut line_width,
                    policy.max_line_width,
                    &mut truncated,
                );
                continue;
            }
            if line_count >= policy.max_lines {
                truncated = true;
                break;
            }
            text.push('\n');
            max_display_width = max_display_width.max(line_width);
            line_width = 0;
            line_count += 1;
            continue;
        }
        if ch == '\t' {
            if policy.preserve_tabs {
                push_char_with_width(
                    &mut text,
                    '\t',
                    &mut line_width,
                    policy.max_line_width,
                    &mut truncated,
                );
            } else {
                let spaces = policy.tab_width.max(1) - (line_width % policy.tab_width.max(1));
                for _ in 0..spaces {
                    push_char_with_width(
                        &mut text,
                        ' ',
                        &mut line_width,
                        policy.max_line_width,
                        &mut truncated,
                    );
                }
            }
            continue;
        }
        if ch.is_control() {
            removed_control_chars += 1;
            push_char_with_width(
                &mut text,
                ' ',
                &mut line_width,
                policy.max_line_width,
                &mut truncated,
            );
            continue;
        }
        if policy.remove_zero_width && is_zero_width(ch) {
            removed_zero_width_chars += 1;
            continue;
        }
        push_char_with_width(
            &mut text,
            ch,
            &mut line_width,
            policy.max_line_width,
            &mut truncated,
        );
    }

    max_display_width = max_display_width.max(line_width);
    SanitizedText {
        text,
        truncated,
        removed_control_chars,
        removed_escape_sequences: stripped.removed_escape_sequences,
        removed_zero_width_chars,
        display_width: max_display_width,
        line_count,
    }
}

pub fn strip_ansi_escapes(input: &str) -> AnsiStripResult {
    let mut text = String::with_capacity(input.len());
    let mut chars = input.chars().peekable();
    let mut removed_escape_sequences = 0usize;

    while let Some(ch) = chars.next() {
        match ch {
            '\u{1b}' => {
                removed_escape_sequences += 1;
                skip_escape_sequence(&mut chars);
            }
            '\u{009b}' => {
                removed_escape_sequences += 1;
                skip_until_final_byte(&mut chars);
            }
            '\u{009d}' => {
                removed_escape_sequences += 1;
                skip_until_bel_or_st(&mut chars);
            }
            _ => text.push(ch),
        }
    }

    AnsiStripResult {
        text,
        removed_escape_sequences,
    }
}

pub fn char_display_width(ch: char) -> usize {
    if ch.is_control() || is_zero_width(ch) {
        return 0;
    }
    let cp = ch as u32;
    if cp >= 0x1100
        && (cp <= 0x115F
            || cp == 0x2329
            || cp == 0x232A
            || (0x2E80..=0x303E).contains(&cp)
            || (0x3040..=0x33BF).contains(&cp)
            || (0x3400..=0x4DBF).contains(&cp)
            || (0x4E00..=0x9FFF).contains(&cp)
            || (0xA000..=0xA4CF).contains(&cp)
            || (0xAC00..=0xD7AF).contains(&cp)
            || (0xF900..=0xFAFF).contains(&cp)
            || (0xFE30..=0xFE6F).contains(&cp)
            || (0xFF01..=0xFF60).contains(&cp)
            || (0xFFE0..=0xFFE6).contains(&cp)
            || (0x20000..=0x2FFFD).contains(&cp)
            || (0x30000..=0x3FFFD).contains(&cp))
    {
        return 2;
    }
    1
}

pub fn str_display_width(input: &str) -> usize {
    input.chars().map(char_display_width).sum()
}

fn push_char_with_width(
    text: &mut String,
    ch: char,
    line_width: &mut usize,
    max_width: usize,
    truncated: &mut bool,
) {
    let width = if ch == '\t' {
        1
    } else {
        char_display_width(ch)
    };
    if max_width != SanitizePolicy::UNLIMITED_WIDTH && *line_width + width > max_width {
        *truncated = true;
        return;
    }
    text.push(ch);
    *line_width += width;
}

fn take_display_width(input: &str, max_width: usize) -> String {
    let mut out = String::new();
    let mut width = 0usize;
    for ch in input.chars() {
        let char_width = char_display_width(ch);
        if width + char_width > max_width {
            break;
        }
        out.push(ch);
        width += char_width;
    }
    out
}

fn is_zero_width(ch: char) -> bool {
    matches!(
        ch as u32,
        0x034F
            | 0x061C
            | 0x115F..=0x1160
            | 0x17B4..=0x17B5
            | 0x180B..=0x180F
            | 0x200B..=0x200F
            | 0x202A..=0x202E
            | 0x2060..=0x206F
            | 0xFE00..=0xFE0F
            | 0xFEFF
    )
}

fn skip_escape_sequence<I>(chars: &mut std::iter::Peekable<I>)
where
    I: Iterator<Item = char>,
{
    match chars.next() {
        Some('[') => skip_until_final_byte(chars),
        Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => skip_until_bel_or_st(chars),
        Some(_) | None => {}
    }
}

fn skip_until_final_byte<I>(chars: &mut std::iter::Peekable<I>)
where
    I: Iterator<Item = char>,
{
    for ch in chars.by_ref() {
        if ('\u{40}'..='\u{7e}').contains(&ch) {
            break;
        }
    }
}

fn skip_until_bel_or_st<I>(chars: &mut std::iter::Peekable<I>)
where
    I: Iterator<Item = char>,
{
    while let Some(ch) = chars.next() {
        if ch == '\u{7}' {
            break;
        }
        if ch == '\u{1b}' && matches!(chars.peek(), Some('\\')) {
            chars.next();
            break;
        }
    }
}

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

    #[test]
    fn display_width_counts_wide_and_zero_width_chars() {
        assert_eq!(char_display_width('a'), 1);
        assert_eq!(char_display_width(''), 2);
        assert_eq!(char_display_width('\u{200b}'), 0);
        assert_eq!(str_display_width("中a"), 3);
    }

    #[test]
    fn sanitize_removes_controls_and_truncates_by_display_width() {
        let text = sanitize_with_policy("ab\x01中cd", SanitizePolicy::single_line(5));

        assert_eq!(text.text, "ab 中");
        assert!(text.truncated);
        assert_eq!(text.removed_control_chars, 1);
    }

    #[test]
    fn ansi_csi_and_osc_sequences_are_removed() {
        let input = "ok\x1b[31m red\x1b[0m \x1b]8;;https://example.com\x07link\x1b]8;;\x07";
        let stripped = strip_ansi_escapes(input);

        assert_eq!(stripped.text, "ok red link");
        assert_eq!(stripped.removed_escape_sequences, 4);
    }

    #[test]
    fn copy_safe_text_removes_invisible_formatting() {
        let input = "safe\u{202e}txt\x1b[2J";

        assert_eq!(copy_safe_text(input), "safetxt");
    }

    #[test]
    fn title_sanitization_strips_terminal_escapes() {
        assert_eq!(sanitize_title("build\x1b[2J output", 40), "build output");
    }
}