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