use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::domain::input::{is_at_mention_boundary, is_at_mention_query_character};
use crate::ui::text_util;
pub const CHAT_INPUT_MAX_VISIBLE_LINES: u16 = 10;
const CHAT_INPUT_BORDER_HEIGHT: u16 = 2;
const CHAT_INPUT_MIN_PANEL_HEIGHT: u16 = CHAT_INPUT_BORDER_HEIGHT + 1;
const CHAT_INPUT_INNER_OFFSET: u16 = 1;
const CHAT_INPUT_PROMPT_PREFIX_WIDTH: u16 = 3;
const QUESTION_PANEL_HELP_HEIGHT: u16 = 1;
const QUESTION_PANEL_SPACER_HEIGHT: u16 = 1;
const SLASH_MENU_BORDER_HEIGHT: u16 = 2;
const CHAT_INPUT_AT_MENTION_COLOR: Color = Color::LightBlue;
const CHAT_INPUT_IMAGE_TOKEN_COLOR: Color = Color::Yellow;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct QuestionPanelLayout {
pub help_height: u16,
pub input_height: u16,
pub question_height: u16,
pub spacer_height: u16,
}
pub fn centered_horizontal_layout(area: Rect) -> std::rc::Rc<[Rect]> {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(2),
Constraint::Percentage(80),
Constraint::Min(2),
])
.split(area)
}
pub fn centered_content_rect(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
let origin_x = area.x + area.width.saturating_sub(width) / 2;
let origin_y = area.y + area.height.saturating_sub(height) / 2;
Rect::new(origin_x, origin_y, width, height)
}
pub fn calculate_input_height(width: u16, input: &str) -> u16 {
let char_count = input.chars().count();
let (_, _, cursor_y) = compute_input_layout(input, width, char_count);
let content_line_count = cursor_y.saturating_add(1);
content_line_count
.min(CHAT_INPUT_MAX_VISIBLE_LINES)
.saturating_add(CHAT_INPUT_BORDER_HEIGHT)
}
pub fn question_panel_layout(
width: u16,
available_height: u16,
question: &str,
input: &str,
max_input_panel_height: u16,
) -> QuestionPanelLayout {
let question_text_height = wrapped_text_height(question, width);
let requested_question_height = if question_text_height > 0 {
question_text_height.saturating_add(1)
} else {
0
};
let requested_input_height = calculate_input_height(width, input)
.min(max_input_panel_height.max(CHAT_INPUT_MIN_PANEL_HEIGHT));
let requested_spacer_height = if requested_question_height > 0 {
QUESTION_PANEL_SPACER_HEIGHT
} else {
0
};
let preferred_total_height = requested_question_height
.saturating_add(requested_input_height)
.saturating_add(requested_spacer_height)
.saturating_add(QUESTION_PANEL_HELP_HEIGHT);
let total_height = preferred_total_height.min(available_height);
let help_height = total_height.min(QUESTION_PANEL_HELP_HEIGHT);
let remaining_height = total_height.saturating_sub(help_height);
let input_height = remaining_height.min(requested_input_height);
let question_and_spacer_height = remaining_height.saturating_sub(input_height);
let spacer_height =
if question_and_spacer_height >= requested_question_height + requested_spacer_height {
requested_spacer_height
} else {
0
};
let question_height = question_and_spacer_height.saturating_sub(spacer_height);
QuestionPanelLayout {
help_height,
input_height,
question_height,
spacer_height,
}
}
pub fn compute_input_layout(
input: &str,
width: u16,
cursor: usize,
) -> (Vec<Line<'static>>, u16, u16) {
let input_layout = compute_input_layout_data(input, width);
let clamped_cursor = cursor.min(input_layout.cursor_positions.len().saturating_sub(1));
let (cursor_x, cursor_y) = input_layout.cursor_positions[clamped_cursor];
(
input_layout.display_lines,
u16::try_from(cursor_x).unwrap_or(u16::MAX),
u16::try_from(cursor_y).unwrap_or(u16::MAX),
)
}
pub fn calculate_input_viewport(
total_line_count: usize,
cursor_y: u16,
viewport_height: u16,
) -> (u16, u16) {
if viewport_height == 0 {
return (0, 0);
}
let total_line_count = u16::try_from(total_line_count).unwrap_or(u16::MAX).max(1);
let clamped_cursor_y = cursor_y.min(total_line_count.saturating_sub(1));
let viewport_height = viewport_height.min(total_line_count);
let max_scroll = total_line_count.saturating_sub(viewport_height);
let scroll_offset = clamped_cursor_y
.saturating_sub(viewport_height.saturating_sub(1))
.min(max_scroll);
let cursor_row = clamped_cursor_y.saturating_sub(scroll_offset);
(scroll_offset, cursor_row)
}
pub fn placeholder_cursor_position(area: Rect) -> (u16, u16) {
(
area.x
.saturating_add(CHAT_INPUT_INNER_OFFSET)
.saturating_add(CHAT_INPUT_PROMPT_PREFIX_WIDTH),
area.y.saturating_add(CHAT_INPUT_INNER_OFFSET),
)
}
pub fn input_cursor_position(area: Rect, cursor_x: u16, cursor_row: u16) -> (u16, u16) {
(
area.x
.saturating_add(CHAT_INPUT_INNER_OFFSET)
.saturating_add(cursor_x),
area.y
.saturating_add(CHAT_INPUT_INNER_OFFSET)
.saturating_add(cursor_row),
)
}
pub fn suggestion_dropdown_height(option_count: usize) -> u16 {
u16::try_from(option_count)
.unwrap_or(u16::MAX)
.saturating_add(SLASH_MENU_BORDER_HEIGHT)
}
pub fn move_input_cursor_up(input: &str, width: u16, cursor: usize) -> usize {
move_input_cursor_vertical(input, width, cursor, VerticalDirection::Up)
}
pub fn move_input_cursor_down(input: &str, width: u16, cursor: usize) -> usize {
move_input_cursor_vertical(input, width, cursor, VerticalDirection::Down)
}
pub fn first_table_column_width(
table_width: u16,
column_constraints: &[Constraint],
column_spacing: u16,
selection_width: u16,
) -> usize {
if column_constraints.is_empty() {
return 0;
}
let [_selection_area, columns_area] =
Layout::horizontal([Constraint::Length(selection_width), Constraint::Fill(0)])
.areas(Rect::new(0, 0, table_width, 1));
let columns = Layout::horizontal(column_constraints.iter().copied())
.spacing(column_spacing)
.split(columns_area);
columns
.first()
.map_or(0, |column| usize::from(column.width))
}
fn wrapped_text_height(text: &str, width: u16) -> u16 {
let wrapped_line_count = text_util::wrap_lines(text, usize::from(width.max(1))).len();
u16::try_from(wrapped_line_count).unwrap_or(u16::MAX).max(1)
}
fn move_input_cursor_vertical(
input: &str,
width: u16,
cursor: usize,
direction: VerticalDirection,
) -> usize {
let input_layout = compute_input_layout_data(input, width);
let clamped_cursor = cursor.min(input_layout.cursor_positions.len().saturating_sub(1));
let (current_x, current_y) = input_layout.cursor_positions[clamped_cursor];
let Some(target_y) = target_line_index(current_y, &input_layout.cursor_positions, direction)
else {
return clamped_cursor;
};
let target_line_width = input_layout
.display_lines
.get(target_y)
.map_or(0, Line::width);
let target_x = current_x.min(target_line_width);
select_cursor_on_line(
target_y,
target_x,
&input_layout.cursor_positions,
clamped_cursor,
)
}
fn compute_input_layout_data(input: &str, width: u16) -> InputLayout {
let inner_width = width.saturating_sub(2) as usize;
let prefix = " › ";
let prefix_span = Span::styled(
prefix,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let prefix_width = prefix_span.width();
let continuation_padding = " ".repeat(prefix_width);
let mut display_lines = Vec::new();
let mut cursor_positions = Vec::with_capacity(input.chars().count() + 1);
let mut current_line_spans = vec![prefix_span];
let mut current_width = prefix_width;
let mut line_index: usize = 0;
let mut in_mention = false;
let mut image_token_end: Option<usize> = None;
let mut last_ch = None;
let input_chars = input.chars().collect::<Vec<_>>();
for (character_index, ch) in input_chars.iter().copied().enumerate() {
if image_token_end.is_some_and(|end_index| character_index >= end_index) {
image_token_end = None;
}
if ch == '\n' {
in_mention = false;
image_token_end = None;
cursor_positions.push((current_width, line_index));
display_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
current_line_spans = vec![Span::raw(continuation_padding.clone())];
current_width = prefix_width;
line_index += 1;
last_ch = Some(ch);
continue;
}
let is_word_start = !ch.is_whitespace()
&& (character_index == 0 || input_chars[character_index - 1].is_whitespace());
if is_word_start {
let word_width = input_chars
.iter()
.skip(character_index)
.take_while(|next_ch| !next_ch.is_whitespace())
.map(|next_ch| Span::raw(next_ch.to_string()).width())
.sum::<usize>();
let line_has_content = current_width > prefix_width;
if line_has_content && current_width + word_width > inner_width {
display_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
current_line_spans = vec![Span::raw(continuation_padding.clone())];
current_width = prefix_width;
line_index += 1;
}
}
if ch == '@' && is_at_mention_boundary(last_ch) {
in_mention = true;
} else if in_mention && !is_at_mention_query_character(ch) {
in_mention = false;
}
if image_token_end.is_none() && ch == '[' {
image_token_end = image_token_end_index(&input_chars, character_index);
}
let is_image_token = image_token_end.is_some_and(|end_index| character_index < end_index);
let style = if is_image_token {
Style::default()
.fg(CHAT_INPUT_IMAGE_TOKEN_COLOR)
.add_modifier(Modifier::BOLD)
} else if in_mention {
Style::default().fg(CHAT_INPUT_AT_MENTION_COLOR)
} else {
Style::default()
};
let char_span = Span::styled(ch.to_string(), style);
let char_width = char_span.width();
if current_width + char_width > inner_width {
display_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
current_line_spans = vec![Span::raw(continuation_padding.clone())];
current_width = prefix_width;
line_index += 1;
}
cursor_positions.push((current_width, line_index));
current_line_spans.push(char_span);
current_width += char_width;
last_ch = Some(ch);
}
if current_width >= inner_width {
cursor_positions.push((prefix_width, line_index + 1));
} else {
cursor_positions.push((current_width, line_index));
}
if !current_line_spans.is_empty() {
display_lines.push(Line::from(current_line_spans));
}
if display_lines.is_empty() {
display_lines.push(Line::from(""));
}
InputLayout {
cursor_positions,
display_lines,
}
}
fn image_token_end_index(input_chars: &[char], start_index: usize) -> Option<usize> {
let token_body = input_chars.get(start_index..)?;
if token_body.len() < "[Image #1]".chars().count() || token_body.first() != Some(&'[') {
return None;
}
let image_prefix = ['[', 'I', 'm', 'a', 'g', 'e', ' ', '#'];
if token_body.get(..image_prefix.len())? != image_prefix {
return None;
}
let mut scan_index = start_index + image_prefix.len();
let mut saw_digit = false;
while let Some(ch) = input_chars.get(scan_index) {
if ch.is_ascii_digit() {
saw_digit = true;
scan_index += 1;
continue;
}
if *ch == ']' && saw_digit {
return Some(scan_index + 1);
}
return None;
}
None
}
fn target_line_index(
current_y: usize,
cursor_positions: &[(usize, usize)],
direction: VerticalDirection,
) -> Option<usize> {
match direction {
VerticalDirection::Up => current_y.checked_sub(1),
VerticalDirection::Down => {
let max_y = cursor_positions
.iter()
.map(|(_, cursor_y)| *cursor_y)
.max()
.unwrap_or(0);
if current_y >= max_y {
None
} else {
Some(current_y + 1)
}
}
}
}
fn select_cursor_on_line(
target_y: usize,
target_x: usize,
cursor_positions: &[(usize, usize)],
fallback_cursor: usize,
) -> usize {
let mut best_cursor_on_left: Option<(usize, usize)> = None;
let mut nearest_cursor_on_right: Option<(usize, usize)> = None;
for (cursor_index, (cursor_x, cursor_y)) in cursor_positions.iter().copied().enumerate() {
if cursor_y != target_y {
continue;
}
if cursor_x <= target_x {
match best_cursor_on_left {
Some((_, best_x)) if cursor_x < best_x => {}
_ => {
best_cursor_on_left = Some((cursor_index, cursor_x));
}
}
} else {
match nearest_cursor_on_right {
Some((_, nearest_x)) if cursor_x > nearest_x => {}
_ => {
nearest_cursor_on_right = Some((cursor_index, cursor_x));
}
}
}
}
best_cursor_on_left
.or(nearest_cursor_on_right)
.map_or(fallback_cursor, |(cursor_index, _)| cursor_index)
}
struct InputLayout {
cursor_positions: Vec<(usize, usize)>,
display_lines: Vec<Line<'static>>,
}
#[derive(Clone, Copy)]
enum VerticalDirection {
Up,
Down,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_centered_content_rect_centers_requested_size() {
let area = Rect::new(10, 5, 40, 12);
let centered_area = centered_content_rect(area, 20, 5);
assert_eq!(centered_area, Rect::new(20, 8, 20, 5));
}
#[test]
fn test_centered_content_rect_clamps_requested_size_to_available_area() {
let area = Rect::new(3, 2, 12, 4);
let centered_area = centered_content_rect(area, 20, 6);
assert_eq!(centered_area, area);
}
#[test]
fn test_calculate_input_height() {
assert_eq!(calculate_input_height(20, ""), 3);
assert_eq!(calculate_input_height(12, "1234567"), 4);
assert_eq!(calculate_input_height(12, "12345678"), 4);
assert_eq!(calculate_input_height(12, "12345671234567890"), 5);
assert_eq!(calculate_input_height(12, &"a".repeat(120)), 12);
}
#[test]
fn test_question_panel_layout_preserves_input_height_before_trimming_question() {
let question = "Need a detailed migration plan with rollback guidance?";
let input = "Use two phases.";
let panel_layout = question_panel_layout(28, 4, question, input, 10);
assert_eq!(
panel_layout,
QuestionPanelLayout {
help_height: 1,
input_height: 3,
question_height: 0,
spacer_height: 0,
}
);
}
#[test]
fn test_question_panel_layout_uses_wrapped_question_height_when_space_allows() {
let question = "Need a detailed migration plan with rollback guidance?";
let input = "Use two phases.";
let panel_layout = question_panel_layout(28, 9, question, input, 10);
assert_eq!(
panel_layout,
QuestionPanelLayout {
help_height: 1,
input_height: 3,
question_height: 3,
spacer_height: 1,
}
);
}
#[test]
fn test_question_panel_layout_drops_spacer_before_last_question_line() {
let question = "Need a detailed migration plan with rollback guidance?";
let input = "Use two phases.";
let panel_layout = question_panel_layout(28, 6, question, input, 10);
assert_eq!(
panel_layout,
QuestionPanelLayout {
help_height: 1,
input_height: 3,
question_height: 2,
spacer_height: 0,
}
);
}
#[test]
fn test_calculate_input_viewport_without_scroll() {
let total_line_count = 4;
let cursor_y = 2;
let viewport_height = 10;
let (scroll_offset, cursor_row) =
calculate_input_viewport(total_line_count, cursor_y, viewport_height);
assert_eq!(scroll_offset, 0);
assert_eq!(cursor_row, 2);
}
#[test]
fn test_calculate_input_viewport_with_scroll() {
let total_line_count = 20;
let cursor_y = 15;
let viewport_height = 10;
let (scroll_offset, cursor_row) =
calculate_input_viewport(total_line_count, cursor_y, viewport_height);
assert_eq!(scroll_offset, 6);
assert_eq!(cursor_row, 9);
}
#[test]
fn test_calculate_input_viewport_clamps_cursor_to_last_line() {
let total_line_count = 3;
let cursor_y = 10;
let viewport_height = 2;
let (scroll_offset, cursor_row) =
calculate_input_viewport(total_line_count, cursor_y, viewport_height);
assert_eq!(scroll_offset, 1);
assert_eq!(cursor_row, 1);
}
#[test]
fn test_placeholder_cursor_position_accounts_for_border_and_prompt_prefix() {
let area = Rect::new(10, 5, 40, 4);
let cursor_position = placeholder_cursor_position(area);
assert_eq!(cursor_position, (14, 6));
}
#[test]
fn test_input_cursor_position_accounts_for_border_and_offsets() {
let area = Rect::new(10, 5, 40, 6);
let cursor_x = 7;
let cursor_row = 2;
let cursor_position = input_cursor_position(area, cursor_x, cursor_row);
assert_eq!(cursor_position, (18, 8));
}
#[test]
fn test_suggestion_dropdown_height_includes_border_lines() {
let option_count = 4;
let dropdown_height = suggestion_dropdown_height(option_count);
assert_eq!(dropdown_height, 6);
}
#[test]
fn test_suggestion_dropdown_height_saturates_at_u16_max() {
let option_count = usize::MAX;
let dropdown_height = suggestion_dropdown_height(option_count);
assert_eq!(dropdown_height, u16::MAX);
}
#[test]
fn test_compute_input_layout_empty() {
let input = "";
let width = 20;
let (lines, cursor_x, cursor_y) = compute_input_layout(input, width, 0);
assert_eq!(lines.len(), 1);
assert_eq!(cursor_x, 3); assert_eq!(cursor_y, 0);
}
#[test]
fn test_compute_input_layout_single_line() {
let input = "test";
let width = 20;
let cursor = input.chars().count();
let (lines, cursor_x, cursor_y) = compute_input_layout(input, width, cursor);
assert_eq!(lines.len(), 1);
assert_eq!(cursor_x, 7); assert_eq!(cursor_y, 0);
}
#[test]
fn test_compute_input_layout_exact_fit() {
let input = "1234567";
let width = 12; let cursor = input.chars().count();
let (lines, cursor_x, cursor_y) = compute_input_layout(input, width, cursor);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].width(), 10);
assert_eq!(cursor_x, 3);
assert_eq!(cursor_y, 1);
}
#[test]
fn test_compute_input_layout_wrap() {
let input = "12345678";
let width = 12;
let cursor = input.chars().count();
let (lines, cursor_x, cursor_y) = compute_input_layout(input, width, cursor);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].width(), 10);
assert_eq!(lines[1].width(), 4);
assert_eq!(lines[1].to_string(), " 8");
assert_eq!(cursor_x, 4);
assert_eq!(cursor_y, 1);
}
#[test]
fn test_compute_input_layout_wraps_whole_words_when_they_fit_on_next_line() {
let input = "abc def";
let width = 11;
let cursor = input.chars().count();
let (lines, cursor_x, cursor_y) = compute_input_layout(input, width, cursor);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].to_string(), " › abc ");
assert_eq!(lines[1].to_string(), " def");
assert_eq!(cursor_x, 6);
assert_eq!(cursor_y, 1);
}
#[test]
fn test_compute_input_layout_multiline_exact_fit() {
let input = "1234567".to_owned() + "1234567890";
let width = 12;
let cursor = input.chars().count();
let (lines, cursor_x, cursor_y) = compute_input_layout(&input, width, cursor);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].width(), 10);
assert_eq!(lines[1].width(), 10);
assert_eq!(lines[2].width(), 6);
assert_eq!(cursor_x, 6);
assert_eq!(cursor_y, 2);
}
#[test]
fn test_compute_input_layout_cursor_at_start() {
let input = "hello";
let width = 20;
let (_, cursor_x, cursor_y) = compute_input_layout(input, width, 0);
assert_eq!(cursor_x, 3);
assert_eq!(cursor_y, 0);
}
#[test]
fn test_compute_input_layout_cursor_in_middle() {
let input = "hello";
let width = 20;
let (_, cursor_x, cursor_y) = compute_input_layout(input, width, 2);
assert_eq!(cursor_x, 5);
assert_eq!(cursor_y, 0);
}
#[test]
fn test_compute_input_layout_cursor_before_wrapped_char() {
let input = "12345678";
let width = 12;
let (_, cursor_x, cursor_y) = compute_input_layout(input, width, 7);
assert_eq!(cursor_x, 3);
assert_eq!(cursor_y, 1);
}
#[test]
fn test_compute_input_layout_cursor_moves_to_next_line_before_wrapped_word() {
let input = "abc def";
let width = 11;
let (_, cursor_x, cursor_y) = compute_input_layout(input, width, 4);
assert_eq!(cursor_x, 3);
assert_eq!(cursor_y, 1);
}
#[test]
fn test_move_input_cursor_up_on_wrapped_layout() {
let input = "12345678";
let width = 12;
let cursor = input.chars().count();
let cursor = move_input_cursor_up(input, width, cursor);
assert_eq!(cursor, 1);
}
#[test]
fn test_move_input_cursor_down_on_wrapped_layout() {
let input = "12345678";
let width = 12;
let cursor = 1;
let cursor = move_input_cursor_down(input, width, cursor);
assert_eq!(cursor, input.chars().count());
}
#[test]
fn test_compute_input_layout_explicit_newline() {
let input = "ab\ncd";
let width = 20;
let cursor = input.chars().count();
let (lines, cursor_x, cursor_y) = compute_input_layout(input, width, cursor);
assert_eq!(lines.len(), 2);
assert_eq!(lines[1].to_string(), " cd");
assert_eq!(cursor_x, 5); assert_eq!(cursor_y, 1);
}
#[test]
fn test_compute_input_layout_multiple_newlines() {
let input = "a\n\nb";
let width = 20;
let cursor = input.chars().count();
let (lines, cursor_x, cursor_y) = compute_input_layout(input, width, cursor);
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].to_string(), " ");
assert_eq!(cursor_x, 4);
assert_eq!(cursor_y, 2);
}
#[test]
fn test_compute_input_layout_cursor_on_second_line() {
let input = "ab\ncd";
let width = 20;
let (_, cursor_x, cursor_y) = compute_input_layout(input, width, 3);
assert_eq!(cursor_x, 3);
assert_eq!(cursor_y, 1);
}
#[test]
fn test_first_table_column_width_uses_remaining_layout_space() {
let constraints = [
Constraint::Fill(1),
Constraint::Length(7),
Constraint::Length(5),
Constraint::Length(4),
Constraint::Length(6),
];
let width = first_table_column_width(50, &constraints, 1, 3);
assert_eq!(width, 21);
}
#[test]
fn test_compute_input_layout_at_mention_highlighting() {
let input = "hello @file world";
let width = 40;
let (lines, _, _) = compute_input_layout(input, width, 0);
let line = &lines[0];
let spans = &line.spans;
assert_eq!(spans[0].content, " › ");
for span in spans.iter().take(7).skip(1) {
assert_eq!(span.style.fg, None);
}
for span in spans.iter().take(12).skip(7) {
assert_eq!(span.style.fg, Some(CHAT_INPUT_AT_MENTION_COLOR));
}
for span in spans.iter().take(18).skip(12) {
assert_eq!(span.style.fg, None);
}
}
#[test]
fn test_compute_input_layout_at_mention_highlighting_stops_before_trailing_comma() {
let input = "hello @file, world";
let width = 40;
let (lines, _, _) = compute_input_layout(input, width, 0);
let line = &lines[0];
let spans = &line.spans;
for span in spans.iter().take(12).skip(7) {
assert_eq!(span.style.fg, Some(CHAT_INPUT_AT_MENTION_COLOR));
}
assert_eq!(spans[12].content, ",");
assert_eq!(spans[12].style.fg, None);
}
#[test]
fn test_compute_input_layout_at_mention_highlighting_stops_before_trailing_parenthesis() {
let input = "hello @file) world";
let width = 40;
let (lines, _, _) = compute_input_layout(input, width, 0);
let line = &lines[0];
let spans = &line.spans;
for span in spans.iter().take(12).skip(7) {
assert_eq!(span.style.fg, Some(CHAT_INPUT_AT_MENTION_COLOR));
}
assert_eq!(spans[12].content, ")");
assert_eq!(spans[12].style.fg, None);
}
#[test]
fn test_compute_input_layout_highlights_parenthesized_at_mention() {
let input = "review (@src/main.rs)";
let width = 40;
let (lines, _, _) = compute_input_layout(input, width, 0);
let line = &lines[0];
let spans = &line.spans;
for span in spans.iter().take(21).skip(9) {
assert_eq!(span.style.fg, Some(CHAT_INPUT_AT_MENTION_COLOR));
}
assert_eq!(spans[21].content, ")");
assert_eq!(spans[21].style.fg, None);
}
#[test]
fn test_compute_input_layout_no_highlight_for_email() {
let input = "email@example.com";
let width = 40;
let (lines, _, _) = compute_input_layout(input, width, 0);
let line = &lines[0];
let spans = &line.spans;
for span in spans.iter().skip(1) {
assert_eq!(span.style.fg, None);
}
}
#[test]
fn test_compute_input_layout_highlights_image_placeholder_tokens() {
let input = "Review [Image #12] before merge";
let width = 60;
let (lines, _, _) = compute_input_layout(input, width, 0);
let line = &lines[0];
let spans = &line.spans;
let image_start = 1 + "Review ".chars().count();
let image_end = image_start + "[Image #12]".chars().count();
for span in spans.iter().take(image_end).skip(image_start) {
assert_eq!(span.style.fg, Some(CHAT_INPUT_IMAGE_TOKEN_COLOR));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
}
#[test]
fn test_compute_input_layout_does_not_highlight_invalid_image_like_tokens() {
let input = "Review [Image #] before merge";
let width = 60;
let (lines, _, _) = compute_input_layout(input, width, 0);
let line = &lines[0];
let spans = &line.spans;
let token_start = 1 + "Review ".chars().count();
let token_end = token_start + "[Image #]".chars().count();
for span in spans.iter().take(token_end).skip(token_start) {
assert_eq!(span.style.fg, None);
}
}
}