rae 0.1.13

Renderer-neutral Rust design catalog for desktop widgets, layouts, sanitized output, and GLSL shader primitives.
Documentation
use crate::{
    sanitize::{
        char_display_width, sanitize_title, sanitize_with_policy, str_display_width, SanitizePolicy,
    },
    widget::TranscriptItem,
};

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

const MAX_TRANSCRIPT_SOURCE_LINES: usize = 20_000;

#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TranscriptRow {
    pub role: String,
    pub text: String,
    pub muted: bool,
    pub continuation: bool,
}

#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TranscriptView {
    pub rows: Vec<TranscriptRow>,
    pub total_rows: usize,
    pub visible_rows: usize,
    pub max_scroll: usize,
    pub scroll: usize,
}

#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TranscriptViewport {
    pub width_px: f32,
    pub height_px: f32,
    pub glyph_width_px: f32,
    pub line_height_px: f32,
    pub scroll_lines: usize,
}

impl TranscriptViewport {
    pub fn new(width_px: f32, height_px: f32) -> Self {
        Self {
            width_px,
            height_px,
            glyph_width_px: 9.5,
            line_height_px: 22.0,
            scroll_lines: 0,
        }
    }

    pub fn with_metrics(mut self, glyph_width_px: f32, line_height_px: f32) -> Self {
        self.glyph_width_px = finite_positive(glyph_width_px, 1.0);
        self.line_height_px = finite_positive(line_height_px, 1.0);
        self
    }

    pub fn with_scroll(mut self, scroll_lines: usize) -> Self {
        self.scroll_lines = scroll_lines;
        self
    }

    pub fn columns(self) -> usize {
        let width = finite_non_negative(self.width_px);
        let glyph_width = finite_positive(self.glyph_width_px, 9.5);
        (width / glyph_width).floor().clamp(12.0, 10_000.0) as usize
    }

    pub fn rows_visible(self) -> usize {
        let height = finite_non_negative(self.height_px);
        let line_height = finite_positive(self.line_height_px, 22.0);
        (height / line_height).floor().clamp(1.0, 10_000.0) as usize
    }

    pub fn materialize(self, items: &[TranscriptItem]) -> TranscriptView {
        let columns = self.columns();
        let prepared = items
            .iter()
            .map(|item| PreparedTranscriptItem::new(item, columns))
            .collect::<Vec<_>>();
        let total_rows = prepared.iter().fold(0usize, |total, item| {
            total.saturating_add(count_item_rows(item, columns))
        });
        let visible_rows = self.rows_visible();
        let max_scroll = total_rows.saturating_sub(visible_rows);
        let scroll = self.scroll_lines.min(max_scroll);
        let end = total_rows.saturating_sub(scroll);
        let start = end.saturating_sub(visible_rows);
        let mut rows = Vec::with_capacity(visible_rows.min(total_rows));
        let mut row_index = 0usize;
        for item in &prepared {
            collect_item_rows(item, columns, start, end, &mut row_index, &mut rows);
            if row_index >= end {
                break;
            }
        }

        TranscriptView {
            rows,
            total_rows,
            visible_rows,
            max_scroll,
            scroll,
        }
    }
}

struct PreparedTranscriptItem {
    role: String,
    content: String,
    detail: String,
}

impl PreparedTranscriptItem {
    fn new(item: &TranscriptItem, columns: usize) -> Self {
        Self {
            role: sanitize_title(&item.role, 64),
            content: sanitize_transcript_text(&item.content, columns),
            detail: sanitize_transcript_text(&item.detail, columns),
        }
    }
}

fn sanitize_transcript_text(input: &str, columns: usize) -> String {
    sanitize_with_policy(
        input,
        SanitizePolicy {
            max_line_width: columns.saturating_mul(8).clamp(columns.max(1), 10_000),
            max_lines: MAX_TRANSCRIPT_SOURCE_LINES,
            ..SanitizePolicy::plain_text()
        },
    )
    .text
}

fn count_item_rows(item: &PreparedTranscriptItem, columns: usize) -> usize {
    let content_rows = emit_wrapped_text(&item.content, columns, |_| {});
    let detail_rows = if item.detail.trim().is_empty() {
        0
    } else {
        emit_wrapped_text(&item.detail, columns, |_| {})
    };
    content_rows.saturating_add(detail_rows).saturating_add(1)
}

fn collect_item_rows(
    item: &PreparedTranscriptItem,
    columns: usize,
    start: usize,
    end: usize,
    row_index: &mut usize,
    rows: &mut Vec<TranscriptRow>,
) {
    let mut content_index = 0usize;
    emit_wrapped_text(&item.content, columns, |text| {
        push_visible_row(
            rows,
            start,
            end,
            *row_index,
            TranscriptRow {
                role: if content_index == 0 {
                    item.role.clone()
                } else {
                    String::new()
                },
                text,
                muted: false,
                continuation: content_index > 0,
            },
        );
        content_index += 1;
        *row_index = row_index.saturating_add(1);
    });

    if !item.detail.trim().is_empty() {
        emit_wrapped_text(&item.detail, columns, |text| {
            push_visible_row(
                rows,
                start,
                end,
                *row_index,
                TranscriptRow {
                    role: String::new(),
                    text,
                    muted: true,
                    continuation: true,
                },
            );
            *row_index = row_index.saturating_add(1);
        });
    }

    push_visible_row(
        rows,
        start,
        end,
        *row_index,
        TranscriptRow {
            role: String::new(),
            text: String::new(),
            muted: true,
            continuation: true,
        },
    );
    *row_index = row_index.saturating_add(1);
}

fn push_visible_row(
    rows: &mut Vec<TranscriptRow>,
    start: usize,
    end: usize,
    row_index: usize,
    row: TranscriptRow,
) {
    if (start..end).contains(&row_index) {
        rows.push(row);
    }
}

fn finite_positive(value: f32, fallback: f32) -> f32 {
    if value.is_finite() && value > 0.0 {
        value
    } else {
        fallback
    }
}

fn finite_non_negative(value: f32) -> f32 {
    if value.is_finite() && value > 0.0 {
        value
    } else {
        0.0
    }
}

fn emit_wrapped_text(text: &str, columns: usize, mut emit: impl FnMut(String)) -> usize {
    let columns = columns.max(1);
    let mut rows = 0usize;
    for source_line in text.lines() {
        let mut line = String::new();
        let mut line_width = 0usize;
        for word in source_line.split_whitespace() {
            let word_width = str_display_width(word);
            if word_width > columns {
                if !line.is_empty() {
                    rows += 1;
                    emit(line);
                    line = String::new();
                    line_width = 0;
                }
                rows += emit_long_word_chunks(word, columns, &mut emit);
                continue;
            }

            let spacing = if line.is_empty() { 0 } else { 1 };
            if line_width + spacing + word_width > columns && !line.is_empty() {
                rows += 1;
                emit(line);
                line = word.to_string();
                line_width = word_width;
            } else if line.is_empty() {
                line.push_str(word);
                line_width = word_width;
            } else {
                line.push(' ');
                line.push_str(word);
                line_width += 1 + word_width;
            }
        }
        if line.is_empty() {
            rows += 1;
            emit(String::new());
        } else {
            rows += 1;
            emit(line);
        }
    }
    if rows == 0 {
        rows += 1;
        emit(String::new());
    }
    rows
}

fn emit_long_word_chunks(word: &str, columns: usize, emit: &mut impl FnMut(String)) -> usize {
    let mut count = 0usize;
    let mut chunk = String::new();
    let mut width = 0usize;
    for ch in word.chars() {
        let char_width = char_display_width(ch);
        if !chunk.is_empty() && width + char_width > columns {
            count += 1;
            emit(chunk);
            chunk = String::new();
            width = 0;
        }
        chunk.push(ch);
        width += char_width;
    }
    if !chunk.is_empty() {
        count += 1;
        emit(chunk);
    }
    count
}

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

    #[test]
    fn transcript_view_clamps_scroll() {
        let items = vec![TranscriptItem::assistant(
            "one two three four five six seven eight nine ten",
            "detail row",
        )];
        let view = TranscriptViewport::new(80.0, 44.0)
            .with_metrics(10.0, 22.0)
            .with_scroll(999)
            .materialize(&items);

        assert_eq!(view.visible_rows, 2);
        assert_eq!(view.scroll, view.max_scroll);
        assert!(view.total_rows > view.rows.len());
    }

    #[test]
    fn transcript_wraps_long_tokens_to_viewport_columns() {
        let viewport = TranscriptViewport::new(80.0, 220.0).with_metrics(10.0, 22.0);
        let columns = viewport.columns();
        let view = viewport.materialize(&[TranscriptItem::assistant(
            "https://example.com/supercalifragilisticexpialidocious",
            "",
        )]);

        assert!(view
            .rows
            .iter()
            .all(|row| str_display_width(&row.text) <= columns));
        assert!(view.total_rows > 2);
    }

    #[test]
    fn malformed_viewport_metrics_use_bounded_minimums() {
        let viewport = TranscriptViewport {
            width_px: f32::INFINITY,
            height_px: f32::NAN,
            glyph_width_px: 0.0,
            line_height_px: 0.0,
            scroll_lines: usize::MAX,
        };
        let view = viewport.materialize(&[TranscriptItem::assistant("hello", "")]);

        assert_eq!(viewport.columns(), 12);
        assert_eq!(viewport.rows_visible(), 1);
        assert_eq!(view.visible_rows, 1);
        assert_eq!(view.rows.len(), 1);
    }

    #[test]
    fn transcript_materialization_retains_only_visible_window_for_large_inputs() {
        let content = vec!["row"; 10_000].join("\n");
        let view = TranscriptViewport::new(120.0, 22.0)
            .with_metrics(10.0, 22.0)
            .materialize(&[TranscriptItem::assistant(content, "")]);

        assert_eq!(view.visible_rows, 1);
        assert_eq!(view.rows.len(), 1);
        assert!(view.total_rows > 1_000);
    }

    #[test]
    fn transcript_materialization_sanitizes_public_text_fields() {
        let item = TranscriptItem {
            role: "\x1b[31massistant\u{202e}".to_string(),
            content: "safe\x1b[2J\u{200b}\u{2028}next".to_string(),
            detail: "detail\x07\u{202e}".to_string(),
        };
        let view = TranscriptViewport::new(240.0, 220.0).materialize(&[item]);
        let joined = view
            .rows
            .iter()
            .map(|row| format!("{}:{}", row.role, row.text))
            .collect::<Vec<_>>()
            .join("\n");

        assert!(!joined.contains('\x1b'));
        assert!(!joined.contains('\x07'));
        assert!(!joined.contains('\u{202e}'));
        assert!(!joined.contains('\u{200b}'));
        assert!(joined.contains("assistant"));
        assert!(joined.contains("safe"));
        assert!(joined.contains("next"));
    }
}