use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Borders;
use crate::domain::input::{is_at_mention_boundary, is_at_mention_query_character};
use crate::ui::style;
pub const CHAT_INPUT_MAX_VISIBLE_LINES: u16 = 10;
const CHAT_INPUT_BORDER_HEIGHT: u16 = 2;
const CHAT_INPUT_INNER_OFFSET: u16 = 1;
const CHAT_INPUT_PROMPT_PREFIX_WIDTH: u16 = 3;
const SLASH_MENU_BORDER_HEIGHT: u16 = 2;
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 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 overlay_area_above(
container_area: Rect,
anchor_area: Rect,
desired_height: u16,
) -> Option<Rect> {
let available_above = anchor_area.y.saturating_sub(container_area.y);
let clamped_height = desired_height.min(available_above);
if clamped_height == 0 {
return None;
}
Some(Rect::new(
anchor_area.x,
anchor_area.y.saturating_sub(clamped_height),
anchor_area.width,
clamped_height,
))
}
pub fn panel_inner_width(area: Rect, borders: Borders) -> usize {
let left_border_width = u16::from(borders.intersects(Borders::LEFT));
let right_border_width = u16::from(borders.intersects(Borders::RIGHT));
usize::from(
area.width
.saturating_sub(left_border_width)
.saturating_sub(right_border_width),
)
}
pub fn bottom_pinned_scroll_offset(
area: Rect,
borders: Borders,
line_count: usize,
scroll_offset: Option<u16>,
) -> u16 {
if let Some(scroll_offset) = scroll_offset {
return scroll_offset;
}
let top_border_height = u16::from(borders.intersects(Borders::TOP));
let bottom_border_height = u16::from(borders.intersects(Borders::BOTTOM));
let inner_height = usize::from(
area.height
.saturating_sub(top_border_height)
.saturating_sub(bottom_border_height),
);
u16::try_from(line_count.saturating_sub(inner_height)).unwrap_or(u16::MAX)
}
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 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(style::palette::accent())
.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(style::palette::warning())
.add_modifier(Modifier::BOLD)
} else if in_mention {
Style::default().fg(style::palette::info())
} 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,
}