use std::collections::VecDeque;
use ratatui::text::Line;
use super::span::SpanKind;
use super::theme::Theme;
use crate::core::message::Message;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TableOverflowPolicy {
WrapCells,
}
#[derive(Clone, Debug)]
pub struct LayoutConfig {
pub width: Option<usize>,
pub markdown_enabled: bool,
pub syntax_enabled: bool,
pub table_overflow_policy: TableOverflowPolicy,
pub user_display_name: Option<String>,
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
width: None,
markdown_enabled: true,
syntax_enabled: true,
table_overflow_policy: TableOverflowPolicy::WrapCells,
user_display_name: None,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct MessageLineSpan {
pub start: usize,
pub len: usize,
}
#[derive(Clone, Debug, Default)]
pub struct Layout {
pub lines: Vec<Line<'static>>,
pub span_metadata: Vec<Vec<SpanKind>>,
pub message_spans: Vec<MessageLineSpan>,
}
pub struct LayoutEngine;
impl LayoutEngine {
pub fn layout_plain_text(
messages: &VecDeque<Message>,
theme: &Theme,
width: Option<usize>,
syntax_enabled: bool,
) -> Layout {
let cfg = LayoutConfig {
width,
markdown_enabled: false,
syntax_enabled,
table_overflow_policy: TableOverflowPolicy::WrapCells,
user_display_name: None,
};
Self::layout_messages(messages, theme, &cfg)
}
pub fn layout_messages(
messages: &VecDeque<Message>,
theme: &Theme,
cfg: &LayoutConfig,
) -> Layout {
let mut lines = Vec::new();
let mut span_metadata = Vec::new();
let mut message_spans = Vec::with_capacity(messages.len());
let mut global_block_index = 0usize;
for msg in messages {
let start = lines.len();
let render_cfg = crate::ui::markdown::MessageRenderConfig::markdown(
cfg.markdown_enabled,
cfg.syntax_enabled,
)
.with_span_metadata()
.with_terminal_width(cfg.width, cfg.table_overflow_policy)
.with_user_display_name(cfg.user_display_name.clone());
let crate::ui::markdown::RenderedMessageDetails {
lines: mut msg_lines,
span_metadata: msg_meta,
} = crate::ui::markdown::render_message_with_config(msg, theme, render_cfg);
let mut msg_metadata = msg_meta.unwrap_or_else(|| {
msg_lines
.iter()
.map(|line| vec![SpanKind::Text; line.spans.len()])
.collect()
});
let mut blocks_in_message = std::collections::HashSet::new();
for line_meta in &msg_metadata {
for kind in line_meta {
if let Some(meta) = kind.code_block_meta() {
blocks_in_message.insert(meta.block_index());
}
}
}
let block_count = blocks_in_message.len();
if block_count > 0 {
let mut sorted_indices: Vec<usize> = blocks_in_message.iter().copied().collect();
sorted_indices.sort_unstable();
let mut index_map = std::collections::HashMap::new();
for (i, local_idx) in sorted_indices.iter().enumerate() {
index_map.insert(*local_idx, global_block_index + i);
}
for line_meta in &mut msg_metadata {
for kind in line_meta {
if let crate::ui::span::SpanKind::CodeBlock(ref mut meta) = kind {
if let Some(&new_idx) = index_map.get(&meta.block_index()) {
*meta = crate::ui::span::CodeBlockMeta::new(
meta.language().map(String::from),
new_idx,
);
}
}
}
}
global_block_index += block_count;
}
let len = msg_lines.len();
span_metadata.extend(msg_metadata);
lines.append(&mut msg_lines);
message_spans.push(MessageLineSpan { start, len });
}
Layout {
lines,
span_metadata,
message_spans,
}
}
}
#[cfg(test)]
mod tests {
use super::{LayoutConfig, LayoutEngine, Theme};
use crate::core::message::Message;
#[cfg(test)]
use crate::core::message::TranscriptRole;
use std::collections::VecDeque;
#[test]
fn markdown_layout_populates_span_metadata() {
let mut messages = VecDeque::new();
messages.push_back(Message {
role: TranscriptRole::Assistant,
content: "Testing a [link](https://example.com) span.".into(),
});
let theme = Theme::dark_default();
let layout = LayoutEngine::layout_messages(&messages, &theme, &LayoutConfig::default());
assert_eq!(layout.lines.len(), layout.span_metadata.len());
let mut saw_link = false;
for kinds in &layout.span_metadata {
if kinds.iter().any(|k| k.is_link()) {
saw_link = true;
break;
}
}
assert!(saw_link, "expected at least one link span kind");
}
#[test]
fn plain_text_layout_synthesizes_metadata() {
let mut messages = VecDeque::new();
messages.push_back(Message {
role: TranscriptRole::User,
content: "Hello there".into(),
});
let theme = Theme::dark_default();
let layout = LayoutEngine::layout_plain_text(&messages, &theme, Some(10), false);
assert_eq!(layout.lines.len(), layout.span_metadata.len());
let mut saw_prefix = false;
for kinds in &layout.span_metadata {
for kind in kinds {
assert!(kind.is_text() || kind.is_prefix());
if kind.is_prefix() {
saw_prefix = true;
}
}
}
assert!(saw_prefix, "expected at least one prefix span kind");
}
#[test]
fn layout_lines_can_be_encoded_with_osc_links() {
let mut messages = VecDeque::new();
messages.push_back(Message {
role: TranscriptRole::Assistant,
content: "[Rust](https://www.rust-lang.org) and [Go](https://go.dev)".into(),
});
let theme = Theme::dark_default();
let layout = LayoutEngine::layout_messages(&messages, &theme, &LayoutConfig::default());
let encoded = crate::ui::osc::encode_lines_with_links(&layout.lines, &layout.span_metadata);
let joined = encoded
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("Rust"));
assert!(joined.contains("Go"));
assert!(joined.matches("\x1b]8;;").count() >= 4);
assert!(joined.matches("\x1b]8;;\x1b\\").count() >= 2);
}
#[test]
fn link_metadata_spans_cover_spaces_within_link_text() {
let mut messages = VecDeque::new();
messages.push_back(Message {
role: TranscriptRole::Assistant,
content: "[associative trails](https://example.com)".into(),
});
let theme = Theme::dark_default();
let layout = LayoutEngine::layout_messages(&messages, &theme, &LayoutConfig::default());
let first_line = layout.lines.first().expect("line");
let first_meta = layout.span_metadata.first().expect("meta");
assert_eq!(first_line.spans.len(), first_meta.len());
assert!(first_meta.iter().all(|kind| kind.is_link()));
}
}