rae 0.1.2

Renderer-neutral widget, layout, state, and GLSL shader primitives for agentic Rust desktop tools.
Documentation
use crate::widget::TranscriptItem;

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

#[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 = glyph_width_px.max(1.0);
        self.line_height_px = line_height_px.max(1.0);
        self
    }

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

    pub fn columns(self) -> usize {
        (self.width_px / self.glyph_width_px).floor().max(12.0) as usize
    }

    pub fn rows_visible(self) -> usize {
        (self.height_px / self.line_height_px).floor().max(1.0) as usize
    }

    pub fn materialize(self, items: &[TranscriptItem]) -> TranscriptView {
        let mut rows = Vec::new();
        for item in items {
            rows.extend(self.wrap_item(item));
        }

        let total_rows = rows.len();
        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 rows = rows[start..end].to_vec();

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

    fn wrap_item(self, item: &TranscriptItem) -> Vec<TranscriptRow> {
        let columns = self.columns();
        let mut rows = Vec::new();
        rows.extend(
            wrap_text(&item.content, columns)
                .into_iter()
                .enumerate()
                .map(|(index, text)| TranscriptRow {
                    role: if index == 0 {
                        item.role.clone()
                    } else {
                        String::new()
                    },
                    text,
                    muted: false,
                    continuation: index > 0,
                }),
        );
        if !item.detail.trim().is_empty() {
            rows.extend(
                wrap_text(&item.detail, columns)
                    .into_iter()
                    .map(|text| TranscriptRow {
                        role: String::new(),
                        text,
                        muted: true,
                        continuation: true,
                    }),
            );
        }
        rows.push(TranscriptRow {
            role: String::new(),
            text: String::new(),
            muted: true,
            continuation: true,
        });
        rows
    }
}

fn wrap_text(text: &str, columns: usize) -> Vec<String> {
    let columns = columns.max(1);
    let mut rows = Vec::new();
    for source_line in text.lines() {
        let mut line = String::new();
        for word in source_line.split_whitespace() {
            let candidate_len = if line.is_empty() {
                word.chars().count()
            } else {
                line.chars().count() + 1 + word.chars().count()
            };
            if candidate_len > columns && !line.is_empty() {
                rows.push(line);
                line = word.to_string();
            } else if line.is_empty() {
                line.push_str(word);
            } else {
                line.push(' ');
                line.push_str(word);
            }
        }
        if line.is_empty() {
            rows.push(String::new());
        } else {
            rows.push(line);
        }
    }
    if rows.is_empty() {
        rows.push(String::new());
    }
    rows
}

#[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());
    }
}