use crate::core::message::{Message, ROLE_ASSISTANT, ROLE_USER};
#[cfg(test)]
use crate::ui::markdown::build_markdown_display_lines;
use crate::ui::span::SpanKind;
use crate::ui::theme::Theme;
use ratatui::{
style::{Color, Modifier, Style},
text::Line,
text::Span,
};
use std::collections::VecDeque;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
/// Handles all scroll-related calculations and line building
pub struct ScrollCalculator;
impl ScrollCalculator {
fn push_emitted_line(
collector_spans: &mut Vec<Span<'static>>,
collector_kinds: &mut Vec<SpanKind>,
out_lines: &mut Vec<Line<'static>>,
out_metadata: &mut Vec<Vec<SpanKind>>,
) {
if collector_spans.is_empty() {
out_lines.push(Line::from(""));
out_metadata.push(Vec::new());
} else {
out_lines.push(Line::from(std::mem::take(collector_spans)));
out_metadata.push(std::mem::take(collector_kinds));
}
}
// Helper to merge adjacent runs with same style and kind, reducing allocations
fn append_run(
collector_spans: &mut Vec<Span<'static>>,
collector_kinds: &mut Vec<SpanKind>,
style: Style,
kind: SpanKind,
text: &str,
) {
if text.is_empty() {
return;
}
if let Some(last_kind) = collector_kinds.last() {
if *last_kind == kind {
if let Some(last_span) = collector_spans.last_mut() {
if last_span.style == style {
last_span.content.to_mut().push_str(text);
return;
}
}
}
}
collector_spans.push(Span::styled(text.to_string(), style));
collector_kinds.push(kind);
}
// Optimized word placement that avoids borrowing issues by taking explicit mutable refs
#[allow(clippy::too_many_arguments)]
fn process_word(
word: &str,
style: Style,
kind: &SpanKind,
cur_spans: &mut Vec<Span<'static>>,
cur_kinds: &mut Vec<SpanKind>,
out_lines: &mut Vec<Line<'static>>,
out_metadata: &mut Vec<Vec<SpanKind>>,
cur_len: &mut usize,
emitted_any: &mut bool,
width: usize,
) {
if word.is_empty() {
return;
}
let w = UnicodeWidthStr::width(word);
if *cur_len > 0 && *cur_len + w > width {
ScrollCalculator::push_emitted_line(cur_spans, cur_kinds, out_lines, out_metadata);
*emitted_any = true;
*cur_len = 0;
}
if w <= width {
ScrollCalculator::append_run(cur_spans, cur_kinds, style, kind.clone(), word);
*cur_len += w;
} else {
// Fallback: split oversized word into graphemes only when needed
for g in UnicodeSegmentation::graphemes(word, true) {
let gw = UnicodeWidthStr::width(g);
if gw == 0 {
continue;
}
if *cur_len > 0 && *cur_len + gw > width {
ScrollCalculator::push_emitted_line(
cur_spans,
cur_kinds,
out_lines,
out_metadata,
);
*emitted_any = true;
*cur_len = 0;
}
ScrollCalculator::append_run(cur_spans, cur_kinds, style, kind.clone(), g);
*cur_len += gw;
}
}
}
/// Pre-wrap the given lines to a specific width, preserving styles and wrapping at word
/// boundaries consistent with the input wrapper (also breaks long tokens when needed).
/// This allows rendering without ratatui's built-in wrapping, ensuring counts match output.
#[cfg_attr(not(test), allow(dead_code))]
pub fn prewrap_lines(lines: &[Line], terminal_width: u16) -> Vec<Line<'static>> {
Self::prewrap_lines_with_metadata(lines, None, terminal_width).0
}
/// Width-aware prewrap that also threads span metadata through the wrapping process.
pub fn prewrap_lines_with_metadata(
lines: &[Line],
span_metadata: Option<&[Vec<SpanKind>]>,
terminal_width: u16,
) -> (Vec<Line<'static>>, Vec<Vec<SpanKind>>) {
let width = terminal_width as usize;
let mut out_lines: Vec<Line<'static>> = Vec::with_capacity(lines.len());
let mut out_metadata: Vec<Vec<SpanKind>> = Vec::with_capacity(lines.len());
// Fast path for zero width: clone spans and metadata without wrapping
if width == 0 {
for (line_idx, line) in lines.iter().enumerate() {
if line.spans.is_empty() {
out_lines.push(Line::from(""));
out_metadata.push(Vec::new());
continue;
}
let mut owned_spans = Vec::with_capacity(line.spans.len());
let mut owned_kinds = Vec::with_capacity(line.spans.len());
for (span_idx, span) in line.spans.iter().enumerate() {
owned_spans.push(Span::styled(span.content.to_string(), span.style));
let kind = span_metadata
.and_then(|meta| meta.get(line_idx))
.and_then(|kinds| kinds.get(span_idx))
.cloned()
.unwrap_or(SpanKind::Text);
owned_kinds.push(kind);
}
out_lines.push(Line::from(owned_spans));
out_metadata.push(owned_kinds);
}
return (out_lines, out_metadata);
}
for (line_idx, line) in lines.iter().enumerate() {
if line.spans.is_empty() {
out_lines.push(Line::from(""));
out_metadata.push(Vec::new());
continue;
}
let mut cur_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 4);
let mut cur_kinds: Vec<SpanKind> = Vec::with_capacity(line.spans.len() + 4);
let mut cur_len: usize = 0;
let mut emitted_any = false;
// Using helper: ScrollCalculator::append_run to merge adjacent runs
// Using helper: ScrollCalculator::process_word to place words without borrow conflicts
for (span_idx, s) in line.spans.iter().enumerate() {
let span_kind = span_metadata
.and_then(|meta| meta.get(line_idx))
.and_then(|kinds| kinds.get(span_idx))
.cloned()
.unwrap_or(SpanKind::Text);
let content = s.content.as_ref();
let mut start = 0;
// Scan by char boundaries, treating plain spaces as breaks; for links, treat any
// Unicode whitespace as break positions to preserve link word grouping.
for (i, ch) in content.char_indices() {
let is_plain_space = ch == ' ';
let is_break_ws = if span_kind.is_link() {
ch.is_whitespace()
} else {
is_plain_space
};
if is_break_ws {
// Place the accumulated word before the whitespace
let word = &content[start..i];
ScrollCalculator::process_word(
word,
s.style,
&span_kind,
&mut cur_spans,
&mut cur_kinds,
&mut out_lines,
&mut out_metadata,
&mut cur_len,
&mut emitted_any,
width,
);
// Place the whitespace itself, width-aware
let grapheme = ch.to_string();
let space_width = UnicodeWidthStr::width(grapheme.as_str());
if space_width > 0 {
if cur_len + space_width <= width {
let kind_for_space = if span_kind.is_link() {
span_kind.clone()
} else {
SpanKind::Text
};
ScrollCalculator::append_run(
&mut cur_spans,
&mut cur_kinds,
s.style,
kind_for_space,
grapheme.as_str(),
);
cur_len += space_width;
} else {
ScrollCalculator::push_emitted_line(
&mut cur_spans,
&mut cur_kinds,
&mut out_lines,
&mut out_metadata,
);
emitted_any = true;
cur_len = 0;
}
}
start = i + ch.len_utf8();
}
}
// Trailing word at end of span
if start < content.len() {
let word = &content[start..];
ScrollCalculator::process_word(
word,
s.style,
&span_kind,
&mut cur_spans,
&mut cur_kinds,
&mut out_lines,
&mut out_metadata,
&mut cur_len,
&mut emitted_any,
width,
);
}
}
if !cur_spans.is_empty() {
ScrollCalculator::push_emitted_line(
&mut cur_spans,
&mut cur_kinds,
&mut out_lines,
&mut out_metadata,
);
emitted_any = true;
}
if !emitted_any {
out_lines.push(Line::from(""));
out_metadata.push(Vec::new());
}
}
(out_lines, out_metadata)
}
/// Build display lines for all messages (tests only)
#[cfg(test)]
pub fn build_display_lines(messages: &VecDeque<Message>) -> Vec<Line<'static>> {
// Backwards-compatible default theme
let theme = Theme::dark_default();
Self::build_display_lines_with_theme(messages, &theme)
}
/// Build display lines using a provided theme (tests only)
#[cfg(test)]
pub fn build_display_lines_with_theme(
messages: &VecDeque<Message>,
theme: &Theme,
) -> Vec<Line<'static>> {
build_markdown_display_lines(messages, theme)
}
/// Build display lines using theme and flags (test-only)
#[cfg(test)]
pub fn build_display_lines_with_theme_and_flags(
messages: &VecDeque<Message>,
theme: &Theme,
markdown_enabled: bool,
syntax_enabled: bool,
) -> Vec<Line<'static>> {
Self::build_display_lines_with_theme_and_flags_and_width(
messages,
theme,
markdown_enabled,
syntax_enabled,
None,
)
}
/// Build display lines using theme, flags, and terminal width for table balancing
pub fn build_display_lines_with_theme_and_flags_and_width(
messages: &VecDeque<Message>,
theme: &Theme,
markdown_enabled: bool,
syntax_enabled: bool,
terminal_width: Option<usize>,
) -> Vec<Line<'static>> {
Self::build_layout_with_theme_and_flags_and_width(
messages,
theme,
markdown_enabled,
syntax_enabled,
terminal_width,
)
.lines
}
pub fn build_layout_with_theme_and_flags_and_width(
messages: &VecDeque<Message>,
theme: &Theme,
markdown_enabled: bool,
syntax_enabled: bool,
terminal_width: Option<usize>,
) -> crate::ui::layout::Layout {
let cfg = crate::ui::layout::LayoutConfig {
width: terminal_width,
markdown_enabled,
syntax_enabled,
table_overflow_policy: crate::ui::layout::TableOverflowPolicy::WrapCells,
user_display_name: None,
};
crate::ui::layout::LayoutEngine::layout_messages(messages, theme, &cfg)
}
/// Build display lines with selection highlighting and terminal width for table balancing
#[cfg_attr(not(test), allow(dead_code))]
#[allow(clippy::too_many_arguments)]
pub fn build_display_lines_with_theme_and_selection_and_flags_and_width(
messages: &VecDeque<Message>,
theme: &Theme,
selected_index: Option<usize>,
highlight: ratatui::style::Style,
markdown_enabled: bool,
syntax_enabled: bool,
terminal_width: Option<usize>,
user_display_name: Option<String>,
) -> Vec<Line<'static>> {
Self::build_layout_with_theme_and_selection_and_flags_and_width(
messages,
theme,
selected_index,
highlight,
markdown_enabled,
syntax_enabled,
terminal_width,
user_display_name,
)
.lines
}
#[allow(clippy::too_many_arguments)]
pub fn build_layout_with_theme_and_selection_and_flags_and_width(
messages: &VecDeque<Message>,
theme: &Theme,
selected_index: Option<usize>,
highlight: ratatui::style::Style,
markdown_enabled: bool,
syntax_enabled: bool,
terminal_width: Option<usize>,
user_display_name: Option<String>,
) -> crate::ui::layout::Layout {
let cfg = crate::ui::layout::LayoutConfig {
width: terminal_width,
markdown_enabled,
syntax_enabled,
table_overflow_policy: crate::ui::layout::TableOverflowPolicy::WrapCells,
user_display_name,
};
let mut layout = crate::ui::layout::LayoutEngine::layout_messages(messages, theme, &cfg);
if let Some(sel) = selected_index {
if let Some(msg) = messages.get(sel) {
if msg.role == ROLE_USER || msg.role == ROLE_ASSISTANT {
if let Some(span) = layout.message_spans.get(sel) {
let highlight_style = theme.selection_highlight_style.patch(highlight);
for (offset, (line, kinds)) in layout
.lines
.iter_mut()
.skip(span.start)
.take(span.len)
.zip(layout.span_metadata.iter().skip(span.start))
.enumerate()
{
let include_empty = offset < span.len.saturating_sub(1);
let has_content =
kinds.iter().zip(line.spans.iter()).any(|(kind, span)| {
!kind.is_prefix() && !span.content.trim().is_empty()
});
if include_empty || has_content {
Self::apply_selection_highlight(
line,
highlight_style,
cfg.width,
include_empty,
Some(kinds),
theme,
);
}
}
}
}
}
}
layout
}
/// Compute a scroll offset that positions the start of a given logical line index
/// within view, taking wrapping and available height into account. The caller should
/// clamp the result to the maximum scroll.
pub fn scroll_offset_to_line_start(
lines: &[Line],
terminal_width: u16,
available_height: u16,
line_index: usize,
) -> u16 {
let prefix = &lines[..line_index.min(lines.len())];
let wrapped_to_start = Self::calculate_wrapped_line_count(prefix, terminal_width);
if wrapped_to_start > available_height.saturating_sub(1) {
wrapped_to_start.saturating_sub(1)
} else {
wrapped_to_start
}
}
/// Build display lines up to a specific message index with flags (test-only)
#[cfg(test)]
pub fn build_display_lines_up_to_with_flags(
messages: &VecDeque<Message>,
theme: &Theme,
markdown_enabled: bool,
syntax_enabled: bool,
up_to_index: usize,
) -> Vec<Line<'static>> {
Self::build_display_lines_up_to_with_flags_and_width(
messages,
theme,
markdown_enabled,
syntax_enabled,
up_to_index,
None,
)
}
/// Build display lines up to a specific message index with terminal width for table balancing
pub fn build_display_lines_up_to_with_flags_and_width(
messages: &VecDeque<Message>,
theme: &Theme,
markdown_enabled: bool,
syntax_enabled: bool,
max_index: usize,
terminal_width: Option<usize>,
) -> Vec<Line<'static>> {
if messages.is_empty() {
return Vec::new();
}
let inclusive_end = max_index.min(messages.len().saturating_sub(1));
let mut subset = VecDeque::with_capacity(inclusive_end + 1);
for msg in messages.iter().take(inclusive_end + 1) {
subset.push_back(msg.clone());
}
let cfg = crate::ui::layout::LayoutConfig {
width: terminal_width,
markdown_enabled,
syntax_enabled,
table_overflow_policy: crate::ui::layout::TableOverflowPolicy::WrapCells,
user_display_name: None,
};
crate::ui::layout::LayoutEngine::layout_messages(&subset, theme, &cfg).lines
}
/// Calculate how many wrapped lines the given lines will take
pub fn calculate_wrapped_line_count(lines: &[Line], _terminal_width: u16) -> u16 {
// Lines provided by the unified layout pipeline are already width-aware.
// Do not perform any additional wrapping here. This is the single source of truth
// for visual line counts used by scroll calculations.
lines.len() as u16
}
/// Calculate how many lines a single text string will wrap to
#[cfg(test)]
fn calculate_word_wrapped_lines_with_leading(text: &str, terminal_width: u16) -> u16 {
let width = terminal_width as usize;
if width == 0 {
return 1;
}
// Always at least one visual line
let mut line_count: u16 = 1;
let mut current_len: usize;
// Count leading spaces explicitly (tabs should be detabbed earlier in markdown)
let mut chars = text.chars().peekable();
let mut leading_spaces = 0usize;
while let Some(&ch) = chars.peek() {
if ch == ' ' {
leading_spaces += 1;
chars.next();
} else {
break;
}
}
if leading_spaces >= width {
line_count = line_count.saturating_add((leading_spaces / width) as u16);
current_len = leading_spaces % width;
} else {
current_len = leading_spaces;
}
// Process remainder as words, but break overlong words to avoid undercounting.
let remainder: String = chars.collect();
for word in remainder.split_whitespace() {
let mut word_len = word.chars().count();
// Insert a single space before the word if not at line start
if current_len > 0 {
if current_len + 1 > width {
line_count = line_count.saturating_add(1);
current_len = 0;
} else {
current_len += 1;
}
}
// Place the word, chunking if it exceeds the available width
loop {
let space_left = width.saturating_sub(current_len);
if word_len <= space_left {
current_len += word_len;
break;
}
if space_left > 0 {
// Fill the current line and wrap
word_len -= space_left;
line_count = line_count.saturating_add(1);
current_len = 0;
} else {
// No space left, wrap to new line
line_count = line_count.saturating_add(1);
current_len = 0;
}
}
}
line_count.max(1)
}
// Wrapper only for tests that reference the original name
#[cfg(test)]
fn calculate_word_wrapped_lines(text: &str, terminal_width: u16) -> u16 {
Self::calculate_word_wrapped_lines_with_leading(text, terminal_width)
}
/// Calculate scroll offset to show the bottom of all messages
#[cfg(test)]
pub fn calculate_scroll_to_bottom(
messages: &VecDeque<Message>,
terminal_width: u16,
available_height: u16,
) -> u16 {
let lines = Self::build_display_lines(messages);
let total_wrapped_lines = Self::calculate_wrapped_line_count(&lines, terminal_width);
if total_wrapped_lines > available_height {
total_wrapped_lines.saturating_sub(available_height)
} else {
0
}
}
/// Calculate scroll offset to show a specific message with exact display flags
pub fn calculate_scroll_to_message_with_flags(
messages: &VecDeque<Message>,
theme: &Theme,
markdown_enabled: bool,
syntax_enabled: bool,
message_index: usize,
terminal_width: u16,
available_height: u16,
) -> u16 {
let lines = Self::build_display_lines_up_to_with_flags_and_width(
messages,
theme,
markdown_enabled,
syntax_enabled,
message_index,
Some(terminal_width as usize),
);
let wrapped_lines = Self::calculate_wrapped_line_count(&lines, terminal_width);
if wrapped_lines > available_height {
wrapped_lines.saturating_sub(available_height)
} else {
0
}
}
/// Calculate maximum scroll offset
#[cfg(test)]
pub fn calculate_max_scroll_offset(
messages: &VecDeque<Message>,
terminal_width: u16,
available_height: u16,
) -> u16 {
Self::calculate_scroll_to_bottom(messages, terminal_width, available_height)
}
}
impl ScrollCalculator {
fn apply_selection_highlight(
line: &mut Line<'static>,
highlight: Style,
width: Option<usize>,
include_empty: bool,
_kinds: Option<&[SpanKind]>,
theme: &Theme,
) {
use crate::utils::color::ColorDepth;
let depth = crate::utils::color::detect_color_depth();
let using_16_color = depth == ColorDepth::X16;
let mut highlight_style = highlight;
if using_16_color {
highlight_style = Style::default().add_modifier(Modifier::REVERSED);
}
let mut has_content = false;
let mut fallback_fg = theme
.assistant_text_style
.fg
.or(theme.user_text_style.fg)
.unwrap_or(Color::White);
for span in &mut line.spans {
if using_16_color {
let base_fg = span.style.fg.unwrap_or(fallback_fg);
fallback_fg = base_fg;
span.style = Style::default().fg(base_fg);
}
span.style = span.style.patch(highlight_style);
if !span.content.trim().is_empty() {
has_content = true;
}
}
if !has_content && !include_empty {
return;
}
if let Some(target_width) = width {
if line.spans.is_empty() {
if include_empty && target_width > 0 {
let padding = " ".repeat(target_width);
*line = Line::from(Span::styled(padding, highlight_style));
}
return;
}
let current_width = line.width();
if current_width < target_width {
let padding = " ".repeat(target_width - current_width);
line.spans.push(Span::styled(padding, highlight_style));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::span::SpanKind;
use crate::ui::theme::Theme;
use crate::utils::test_utils::{
create_test_message, create_test_messages, SAMPLE_HYPERTEXT_PARAGRAPH,
};
use ratatui::style::Style;
use ratatui::text::Line as TLine;
use std::collections::VecDeque;
use std::time::Instant;
use unicode_width::UnicodeWidthStr;
#[test]
fn test_build_display_lines_basic() {
let messages = create_test_messages();
let lines = ScrollCalculator::build_display_lines(&messages);
// Should have lines for each message plus spacing
// Each message gets 2 lines (content + empty spacing)
assert_eq!(lines.len(), 8); // 4 messages * 2 lines each
// Check that user messages start with "You: "
assert!(lines[0].to_string().starts_with("You: "));
assert!(lines[4].to_string().starts_with("You: "));
// Check that assistant messages don't have prefix
assert!(!lines[2].to_string().starts_with("You: "));
assert!(!lines[6].to_string().starts_with("You: "));
}
#[test]
fn test_build_display_lines_up_to() {
let messages = create_test_messages();
let theme = Theme::dark_default();
let lines = ScrollCalculator::build_display_lines_up_to_with_flags(
&messages, &theme, true, true, 1,
);
// Should only include first 2 messages (indices 0 and 1)
assert_eq!(lines.len(), 4); // 2 messages * 2 lines each
assert!(lines[0].to_string().starts_with("You: Hello"));
assert!(lines[2].to_string().contains("Hi there!"));
}
#[test]
fn test_calculate_word_wrapped_lines_single_line() {
// Text that fits in one line
let wrapped = ScrollCalculator::calculate_word_wrapped_lines("Hello world", 20);
assert_eq!(wrapped, 1);
}
#[test]
fn test_calculate_word_wrapped_lines_multiple_lines() {
// Text that needs to wrap
let text = "This is a very long sentence that will definitely need to wrap";
let wrapped = ScrollCalculator::calculate_word_wrapped_lines(text, 20);
assert!(wrapped > 1);
}
#[test]
fn test_calculate_word_wrapped_lines_exact_fit() {
// Text that exactly fits the width
let wrapped = ScrollCalculator::calculate_word_wrapped_lines("Hello world test", 16);
assert_eq!(wrapped, 1);
}
#[test]
fn test_calculate_word_wrapped_lines_single_word_too_long() {
// Single word longer than width should wrap across multiple lines
let wrapped = ScrollCalculator::calculate_word_wrapped_lines(
"supercalifragilisticexpialidocious",
10,
);
assert!(wrapped > 1);
}
#[test]
fn test_calculate_wrapped_line_count_empty_lines() {
let lines = vec![Line::from(""), Line::from(""), Line::from("")];
let count = ScrollCalculator::calculate_wrapped_line_count(&lines, 80);
assert_eq!(count, 3);
}
#[test]
fn test_calculate_wrapped_line_count_mixed_content() {
// Build content via the unified layout engine and compare line counts at different widths
let theme = Theme::dark_default();
let mut messages: VecDeque<Message> = VecDeque::new();
let content = "Short line\n\nThis is a much longer line that might wrap depending on terminal width\nAnother short one";
messages.push_back(Message {
role: "assistant".into(),
content: content.into(),
});
let lines_wide = ScrollCalculator::build_display_lines_with_theme_and_flags_and_width(
&messages,
&theme,
true,
false,
Some(100),
);
// Use plain text path to ensure paragraph wrap is exercised without markdown semantics
let lines_narrow = ScrollCalculator::build_display_lines_with_theme_and_flags_and_width(
&messages,
&theme,
false,
false,
Some(20),
);
// With wide terminal (markdown) and narrow (plain), the narrow width should yield
// at least as many lines, and wrapping should add extra lines relative to the wide view
assert!(lines_narrow.len() >= lines_wide.len());
assert!(lines_narrow.len() > lines_wide.len());
}
#[test]
fn test_calculate_wrapped_line_count_zero_width() {
let lines = vec![Line::from("Any content")];
let count = ScrollCalculator::calculate_wrapped_line_count(&lines, 0);
assert_eq!(count, 1);
}
#[test]
fn test_plain_text_long_line_wrapping() {
// Plain-text mode should wrap long lines when a terminal width is provided
let theme = Theme::dark_default();
let mut messages: VecDeque<Message> = VecDeque::new();
let long = "This is a very long plain text line without explicit newlines that should wrap when markdown is disabled";
messages.push_back(Message {
role: "assistant".into(),
content: long.into(),
});
let width = 20usize;
let lines = ScrollCalculator::build_display_lines_with_theme_and_flags_and_width(
&messages,
&theme,
false, // markdown disabled
false,
Some(width),
);
// Filter to content lines only (non-empty)
let rendered: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
let content_lines: Vec<String> = rendered.into_iter().filter(|s| !s.is_empty()).collect();
// Should have wrapped into multiple visual lines
assert!(
content_lines.len() > 1,
"Expected multiple wrapped lines in plain-text mode"
);
// No content line should exceed the specified width
for (i, s) in content_lines.iter().enumerate() {
assert!(
s.chars().count() <= width,
"Wrapped line {} exceeds width {}: '{}' (len={})",
i,
width,
s,
s.len()
);
}
// Content must be preserved (no ellipsis)
let joined = content_lines.join(" ");
assert!(!joined.contains('โฆ'));
assert!(joined.contains("plain text line"));
}
#[test]
fn test_markdown_link_wraps_on_spaces() {
// Links with ordinary spaces should wrap on word boundaries
let style = Style::default();
let line = Line::from(vec![Span::styled("Rust programming language", style)]);
let metadata = vec![vec![SpanKind::link("https://example.com")]];
let (wrapped, _) =
ScrollCalculator::prewrap_lines_with_metadata(&[line], Some(&metadata), 15);
let rendered: Vec<String> = wrapped.into_iter().map(|l| l.to_string()).collect();
assert_eq!(rendered, vec!["Rust ", "programming ", "language"]);
}
#[test]
fn test_markdown_link_wraps_on_nbsp() {
// Non-breaking spaces inside markdown links should still allow wrapping
let style = Style::default();
let line = Line::from(vec![Span::styled(
"Rust\u{00A0}programming language",
style,
)]);
let metadata = vec![vec![SpanKind::link("https://example.com")]];
let (wrapped, _) =
ScrollCalculator::prewrap_lines_with_metadata(&[line], Some(&metadata), 15);
let rendered: Vec<String> = wrapped.into_iter().map(|l| l.to_string()).collect();
assert_eq!(rendered, vec!["Rust\u{00A0}", "programming ", "language"]);
}
#[test]
fn test_prewrap_wide_emoji_respects_width() {
let style = Style::default();
let content = "๐๐๐๐๐";
let line = Line::from(vec![Span::styled(content, style)]);
let (wrapped, _) = ScrollCalculator::prewrap_lines_with_metadata(&[line], None, 4);
for (idx, line) in wrapped.iter().enumerate() {
let width: usize = line
.spans
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum();
assert!(
width <= 4,
"Line {} exceeded width: {} > 4 (content: {:?})",
idx,
width,
line.to_string()
);
}
}
#[test]
fn test_prewrap_zwj_sequence_respects_width() {
let style = Style::default();
let content = "๐จโ๐ฉโ๐งโ๐ฆ๐จโ๐ฉโ๐งโ๐ฆ๐จโ๐ฉโ๐งโ๐ฆ";
let line = Line::from(vec![Span::styled(content, style)]);
let (wrapped, _) = ScrollCalculator::prewrap_lines_with_metadata(&[line], None, 4);
assert!(
wrapped.len() > 1,
"Expected wrapped output for clustered emoji"
);
for (idx, line) in wrapped.iter().enumerate() {
let width: usize = line
.spans
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum();
assert!(
width <= 4,
"Line {} exceeded width: {} > 4 (content: {:?})",
idx,
width,
line.to_string()
);
}
}
#[test]
fn test_layout_engine_and_prewrap_preserve_link_words() {
let theme = Theme::dark_default();
let mut messages = VecDeque::new();
messages.push_back(Message {
role: "assistant".into(),
content: SAMPLE_HYPERTEXT_PARAGRAPH.into(),
});
let layout = crate::ui::layout::LayoutEngine::layout_messages(
&messages,
&theme,
&crate::ui::layout::LayoutConfig {
width: Some(158),
markdown_enabled: true,
syntax_enabled: true,
table_overflow_policy: crate::ui::layout::TableOverflowPolicy::WrapCells,
user_display_name: None,
},
);
let (prewrapped, _) = ScrollCalculator::prewrap_lines_with_metadata(
&layout.lines,
Some(&layout.span_metadata),
158,
);
let text = prewrapped
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(
!text.contains("hype\nrtext"),
"prewrap still split the link text mid-word: {:?}",
text
);
}
#[test]
fn test_prewrap_wraps_entire_link_word_when_width_exhausted() {
let style = Style::default();
// 150 columns of padding plus a space, leaving little room for the link to fit
let prefix = "a".repeat(150);
let line = Line::from(vec![
Span::raw(prefix),
Span::raw(" "),
Span::styled("hypertext dreams", style),
]);
let metadata = vec![vec![
SpanKind::Text,
SpanKind::Text,
SpanKind::link("https://example.com"),
]];
let (wrapped, _) =
ScrollCalculator::prewrap_lines_with_metadata(&[line], Some(&metadata), 158);
let rendered: Vec<String> = wrapped.into_iter().map(|l| l.to_string()).collect();
assert!(rendered.iter().any(|s| s.contains("hypertext dreams")));
assert!(
!rendered.join("\n").contains("hype\nrtext"),
"link word still split mid-line: {:?}",
rendered
);
}
#[test]
fn test_calculate_scroll_to_bottom_no_scroll_needed() {
let messages = create_test_messages();
let scroll = ScrollCalculator::calculate_scroll_to_bottom(&messages, 80, 20);
// With wide terminal and high available height, no scroll needed
assert_eq!(scroll, 0);
}
#[test]
fn test_calculate_scroll_to_bottom_scroll_needed() {
let mut messages = VecDeque::new();
// Create many messages to force scrolling
for i in 0..10 {
messages.push_back(create_test_message("user", &format!("Message {i}")));
messages.push_back(create_test_message("assistant", &format!("Response {i}")));
}
let scroll = ScrollCalculator::calculate_scroll_to_bottom(&messages, 80, 5);
// With low available height, should need to scroll
assert!(scroll > 0);
}
#[test]
fn test_calculate_scroll_to_message() {
let messages = create_test_messages();
// Scroll to first message should be 0
let theme = Theme::dark_default();
let scroll_first = ScrollCalculator::calculate_scroll_to_message_with_flags(
&messages, &theme, true, true, 0, 80, 10,
);
assert_eq!(scroll_first, 0);
// Scroll to later message might require scrolling
let scroll_later = ScrollCalculator::calculate_scroll_to_message_with_flags(
&messages, &theme, true, true, 3, 80, 2,
);
assert!(scroll_later > 0);
}
#[test]
fn test_calculate_max_scroll_offset() {
let messages = create_test_messages();
let max_scroll = ScrollCalculator::calculate_max_scroll_offset(&messages, 80, 5);
let scroll_to_bottom = ScrollCalculator::calculate_scroll_to_bottom(&messages, 80, 5);
// Max scroll should equal scroll to bottom
assert_eq!(max_scroll, scroll_to_bottom);
}
#[test]
fn test_app_message_formatting() {
let mut messages = VecDeque::new();
messages.push_back(create_test_message(
crate::core::message::ROLE_APP_INFO,
"App message",
));
let lines = ScrollCalculator::build_display_lines(&messages);
assert_eq!(lines.len(), 2); // App message + spacing
// App messages should not have "You: " prefix
assert!(!lines[0].to_string().starts_with("You: "));
assert!(lines[0].to_string().contains("App message"));
}
#[test]
fn test_empty_message_content() {
let mut messages = VecDeque::new();
messages.push_back(create_test_message("assistant", ""));
let lines = ScrollCalculator::build_display_lines(&messages);
// Empty assistant message should not add any lines
assert_eq!(lines.len(), 0);
}
#[test]
fn test_multiline_assistant_message() {
let mut messages = VecDeque::new();
messages.push_back(create_test_message("assistant", "Line 1\nLine 2\n\nLine 4"));
let lines = ScrollCalculator::build_display_lines(&messages);
// Should have: Line 1, Line 2, empty line, Line 4, spacing = 5 lines
assert_eq!(lines.len(), 5);
}
#[test]
fn test_multiline_user_message() {
let mut messages = VecDeque::new();
messages.push_back(create_test_message("user", "Line 1\nLine 2\n\nLine 4"));
let lines = ScrollCalculator::build_display_lines(&messages);
// Should have: "You: Line 1", " Line 2", empty line, " Line 4", spacing = 5 lines
assert_eq!(lines.len(), 5);
// First line should have "You: " prefix
assert!(lines[0].to_string().starts_with("You: Line 1"));
// Second line should be indented
assert!(lines[1].to_string().starts_with(" Line 2"));
// Third line should be empty
assert_eq!(lines[2].to_string(), "");
// Fourth line should be indented
assert!(lines[3].to_string().starts_with(" Line 4"));
// Fifth line should be empty spacing
assert_eq!(lines[4].to_string(), "");
}
#[test]
fn test_word_wrapping_with_long_paragraph() {
let long_text = "This is a very long paragraph that contains many words and should definitely wrap across multiple lines when displayed in a narrow terminal window. The wrapping should be word-based, not character-based, to match ratatui's behavior.";
let wrapped_narrow = ScrollCalculator::calculate_word_wrapped_lines(long_text, 40);
let wrapped_wide = ScrollCalculator::calculate_word_wrapped_lines(long_text, 300); // Use wider width
// Should wrap more with narrow width
assert!(wrapped_narrow > wrapped_wide);
assert!(wrapped_narrow > 3); // Should definitely wrap
assert_eq!(wrapped_wide, 1); // Should fit in one line when wide enough
}
#[test]
fn test_trimming_behavior() {
let lines = vec![
Line::from(" "), // Only whitespace
Line::from(" content "), // Content with surrounding whitespace
Line::from(""), // Empty
];
let count = ScrollCalculator::calculate_wrapped_line_count(&lines, 80);
// All should count as single lines due to trimming
assert_eq!(count, 3);
}
#[test]
fn table_scroll_height_matches_rendered() {
// Test that prewrap line count matches rendered line count to prevent
// unreachable table bottoms due to width mismatches
let mut messages = VecDeque::new();
let table_content = r#"Here's a test table:
| Government System | Definition |
|-------------------|------------|
| Democracy | A system where power is vested in the people |
| Dictatorship | A form of government where a single person holds absolute power |
| Monarchy | A form of government with a single ruler |
"#;
messages.push_back(create_test_message("assistant", table_content));
let theme = Theme::dark_default();
let terminal_width = 80u16;
// Build display lines using the same path as scroll calculations
// Since we're using the same terminal width for both, the output should be identical
let display_lines = ScrollCalculator::build_display_lines_with_theme_and_flags_and_width(
&messages,
&theme,
true,
false,
Some(terminal_width as usize),
);
let scroll_line_count = display_lines.len();
// Now render using markdown with the same terminal width
let cfg = crate::ui::markdown::MessageRenderConfig::markdown(true, true)
.with_terminal_width(
Some(terminal_width as usize),
crate::ui::layout::TableOverflowPolicy::WrapCells,
);
let rendered = crate::ui::markdown::render_message_with_config(&messages[0], &theme, cfg)
.into_rendered();
let rendered_line_count = rendered.lines.len();
// Key assertion: line counts should match since both use the same width constraint
assert_eq!(
scroll_line_count, rendered_line_count,
"Scroll line count ({}) should match rendered line count ({}). \
This ensures scroll calculations are consistent with rendering.",
scroll_line_count, rendered_line_count
);
// Additional check: verify table content is present
let rendered_str = rendered
.lines
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered_str.contains("Democracy") && rendered_str.contains("Dictatorship"),
"Table content should be present in rendered output"
);
}
#[test]
fn test_selection_highlight_builds_same_number_of_lines() {
let mut messages = VecDeque::new();
messages.push_back(create_test_message("user", "Hello"));
messages.push_back(create_test_message("assistant", "Hi there!"));
messages.push_back(create_test_message("user", "How are you?"));
let theme = Theme::dark_default();
let highlight = Style::default();
let normal = ScrollCalculator::build_display_lines_with_theme(&messages, &theme);
let highlighted =
ScrollCalculator::build_display_lines_with_theme_and_selection_and_flags_and_width(
&messages,
&theme,
Some(0),
highlight,
true,
true,
None,
None,
);
assert_eq!(normal.len(), highlighted.len());
}
#[test]
fn assistant_selection_highlight_changes_rendered_lines() {
let mut messages = VecDeque::new();
messages.push_back(create_test_message("user", "Hello"));
messages.push_back(create_test_message("assistant", "Line 1\nLine 2"));
let theme = Theme::dark_default();
let highlight = Style::default();
let base_layout =
ScrollCalculator::build_layout_with_theme_and_selection_and_flags_and_width(
&messages,
&theme,
None,
highlight,
true,
true,
Some(80),
None,
);
let highlighted_layout =
ScrollCalculator::build_layout_with_theme_and_selection_and_flags_and_width(
&messages,
&theme,
Some(1),
highlight,
true,
true,
Some(80),
None,
);
let span = highlighted_layout
.message_spans
.get(1)
.expect("assistant span");
let mut changed = false;
for offset in 0..span.len {
let idx = span.start + offset;
if base_layout.lines[idx] != highlighted_layout.lines[idx] {
changed = true;
break;
}
}
assert!(changed, "assistant highlight should alter rendered lines");
}
#[test]
fn perf_prewrap_short_history() {
// Synthetic short history with styled spans to exercise prewrap mapping
let theme = Theme::dark_default();
let mut messages: VecDeque<Message> = VecDeque::new();
// Build ~30 lines alternating user/assistant, with words split into separate spans
let base = "lorem ipsum dolor sit amet consectetur adipiscing elit";
for i in 0..15 {
let role = if i % 2 == 0 { "user" } else { "assistant" };
messages.push_back(create_test_message(role, base));
messages.push_back(create_test_message(role, base));
}
// Render to styled lines (markdown on, syntax off for speed)
let lines = ScrollCalculator::build_display_lines_with_theme_and_flags(
&messages, &theme, true, false,
);
// Time multiple prewrap passes to smooth out noise
let width: u16 = 100;
let iters = 50;
let start = Instant::now();
let mut total_lines = 0usize;
for _ in 0..iters {
let pre = ScrollCalculator::prewrap_lines(&lines, width);
total_lines += pre.len();
}
let elapsed = start.elapsed();
// Performance thresholds for short histories (50 iterations):
// - Warn at >= 90ms (non-fatal, prints to stderr)
// - Fail at >= 200ms
let ms = elapsed.as_millis();
if ms >= 200 {
panic!(
"prewrap extremely slow: {:?} for {} total prewrapped lines",
elapsed, total_lines
);
} else if ms >= 90 {
eprintln!(
"Warning: prewrap moderately slow: {:?} for {} total prewrapped lines",
elapsed, total_lines
);
}
}
#[test]
fn perf_prewrap_large_history() {
// Larger synthetic history to exercise scaling
let theme = Theme::dark_default();
let mut messages: VecDeque<Message> = VecDeque::new();
let base = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua";
for i in 0..100 {
let role = if i % 2 == 0 { "user" } else { "assistant" };
messages.push_back(create_test_message(role, base));
messages.push_back(create_test_message(role, base));
}
let lines = ScrollCalculator::build_display_lines_with_theme_and_flags(
&messages, &theme, true, false,
);
let width: u16 = 80;
let iters = 20;
let start = Instant::now();
let mut total_lines = 0usize;
for _ in 0..iters {
let pre = ScrollCalculator::prewrap_lines(&lines, width);
total_lines += pre.len();
}
let elapsed = start.elapsed();
// Warn at moderate times, fail at excessive times for larger histories
let ms = elapsed.as_millis();
if ms >= 1000 {
panic!(
"prewrap extremely slow (large): {:?} for {} total prewrapped lines",
elapsed, total_lines
);
} else if ms >= 400 {
eprintln!(
"Warning: prewrap moderately slow (large): {:?} for {} total prewrapped lines",
elapsed, total_lines
);
}
}
#[test]
fn test_scroll_offset_to_line_start_basic() {
// Three lines: short, long, short. Width forces wrapping of the long line.
let lines = vec![
TLine::from("aaa"),
TLine::from("bbb bbb bbb bbb"),
TLine::from("ccc"),
];
let width = 5u16;
let available = 5u16;
let off0 = ScrollCalculator::scroll_offset_to_line_start(&lines, width, available, 0);
let off1 = ScrollCalculator::scroll_offset_to_line_start(&lines, width, available, 1);
let off2 = ScrollCalculator::scroll_offset_to_line_start(&lines, width, available, 2);
assert_eq!(off0, 0);
assert!(off1 >= 1); // starts after first line
assert!(off2 >= off1); // further down the view
}
#[test]
fn test_prewrap_paragraph_no_leading_spaces_or_lonely_dot() {
let paragraph = "The way language shapes our perception of reality is something that linguists and philosophers have debated for centuries. Do we think in words, or do words simply provide a framework for thoughts that exist beyond language? Some cultures have dozens of words for different types of snow, while others have elaborate systems for describing relationships between family members. These linguistic differences suggest that our vocabulary doesn't just describe our world - it actually influences how we see and understand it.";
let width: u16 = 143;
let line = TLine::from(paragraph);
let pre = ScrollCalculator::prewrap_lines(&[line], width);
assert!(!pre.is_empty());
for l in pre {
let s = l.to_string();
assert!(
!s.starts_with(' '),
"wrapped line starts with space: '{} '",
s
);
assert_ne!(s.trim(), ".", "wrapped line became a lonely '.'");
}
}
}