use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
};
use super::ai_state::AiState;
use crate::scroll::Scrollable;
use crate::theme;
use crate::widgets::{popup, scrollbar};
const HORIZONTAL_PADDING: u16 = 1;
const VERTICAL_PADDING: u16 = 1;
use super::render::layout;
pub use self::content::build_content;
pub use layout::{
AUTOCOMPLETE_RESERVED_WIDTH, calculate_popup_area, calculate_popup_area_with_height,
};
#[path = "render/content.rs"]
mod content;
fn calculate_suggestion_heights(ai_state: &AiState, max_width: u16) -> Vec<u16> {
use crate::ai::render::text::wrap_text;
let mut heights = Vec::with_capacity(ai_state.suggestions.len());
for (i, suggestion) in ai_state.suggestions.iter().enumerate() {
let type_label = suggestion.suggestion_type.label();
let has_selection_number = i < 5;
let prefix = if has_selection_number {
format!("{}. {} ", i + 1, type_label)
} else {
format!("{} ", type_label)
};
let prefix_len = prefix.len();
let query_max_width = max_width.saturating_sub(prefix_len as u16) as usize;
let query_lines = wrap_text(&suggestion.query, query_max_width);
let mut suggestion_height = query_lines.len() as u16;
if !suggestion.description.is_empty() {
let desc_max_width = max_width.saturating_sub(3) as usize;
let desc_lines = wrap_text(&suggestion.description, desc_max_width).len();
suggestion_height = suggestion_height.saturating_add(desc_lines as u16);
}
if i < ai_state.suggestions.len() - 1 {
suggestion_height = suggestion_height.saturating_add(1);
}
heights.push(suggestion_height);
}
heights
}
fn calculate_suggestions_height(ai_state: &AiState, max_width: u16) -> u16 {
let heights = calculate_suggestion_heights(ai_state, max_width);
heights.iter().sum::<u16>()
}
fn render_suggestions_as_widgets(
ai_state: &mut AiState,
frame: &mut Frame,
inner_area: Rect,
max_width: u16,
) {
use crate::ai::render::text::wrap_text;
let heights = calculate_suggestion_heights(ai_state, max_width);
ai_state
.selection
.update_layout(heights.clone(), inner_area.height);
if ai_state.selection.get_selected().is_some() {
ai_state.selection.ensure_selected_visible();
}
let scroll_offset = ai_state.selection.scroll_offset_u16();
let viewport_end = scroll_offset.saturating_add(inner_area.height);
let selected_index = ai_state.selection.get_selected();
let hovered_index = ai_state.selection.get_hovered();
let mut current_y = 0u16;
for (i, suggestion) in ai_state.suggestions.iter().enumerate() {
let suggestion_height = heights[i];
let suggestion_end = current_y.saturating_add(suggestion_height);
if suggestion_end <= scroll_offset {
current_y = suggestion_end;
continue;
}
if current_y >= viewport_end {
break;
}
let render_y = inner_area
.y
.saturating_add(current_y.saturating_sub(scroll_offset));
let visible_start = current_y.max(scroll_offset);
let visible_end = suggestion_end.min(viewport_end);
let visible_height = visible_end.saturating_sub(visible_start);
let render_area = Rect {
x: inner_area.x,
y: render_y,
width: inner_area.width,
height: visible_height,
};
let mut lines: Vec<Line> = Vec::new();
let is_selected = selected_index == Some(i);
let is_hovered = hovered_index == Some(i) && !is_selected;
let type_color = suggestion.suggestion_type.color();
let type_label = suggestion.suggestion_type.label();
let has_selection_number = i < 5;
let prefix = if has_selection_number {
format!("{}. {} ", i + 1, type_label)
} else {
format!("{} ", type_label)
};
let prefix_len = prefix.len();
let query_max_width = max_width.saturating_sub(prefix_len as u16) as usize;
let query_lines = wrap_text(&suggestion.query, query_max_width);
if let Some(first_query_line) = query_lines.first() {
let mut spans = Vec::new();
if has_selection_number {
let style = if is_selected {
Style::default().fg(theme::ai::SUGGESTION_TEXT_SELECTED)
} else {
Style::default().fg(theme::ai::SUGGESTION_TEXT_NORMAL)
};
spans.push(Span::styled(format!("{}. ", i + 1), style));
}
let type_style = Style::default().fg(type_color).add_modifier(Modifier::BOLD);
spans.push(Span::styled(type_label.to_string(), type_style));
spans.push(Span::styled(" ", Style::default()));
let query_style = Style::default().fg(theme::ai::QUERY_TEXT);
spans.push(Span::styled(first_query_line.clone(), query_style));
lines.push(Line::from(spans));
}
for query_line in query_lines.iter().skip(1) {
let indent = " ".repeat(prefix_len);
let style = Style::default().fg(theme::ai::QUERY_TEXT);
lines.push(Line::from(Span::styled(
format!("{}{}", indent, query_line),
style,
)));
}
if !suggestion.description.is_empty() {
let desc_max_width = max_width.saturating_sub(3) as usize;
for desc_line in wrap_text(&suggestion.description, desc_max_width) {
let style = if is_selected {
Style::default().fg(theme::ai::SUGGESTION_DESC_MUTED)
} else {
Style::default().fg(theme::ai::SUGGESTION_DESC_NORMAL)
};
lines.push(Line::from(Span::styled(format!(" {}", desc_line), style)));
}
}
if i < ai_state.suggestions.len() - 1 {
lines.push(Line::from(""));
}
let style = if is_selected {
Style::default().bg(theme::ai::SUGGESTION_SELECTED_BG)
} else if is_hovered {
Style::default().bg(theme::ai::SUGGESTION_HOVERED_BG)
} else {
Style::default()
};
let line_scroll_offset = if current_y < scroll_offset {
scroll_offset.saturating_sub(current_y)
} else {
0
};
let paragraph = Paragraph::new(lines)
.style(style)
.scroll((line_scroll_offset, 0));
frame.render_widget(paragraph, render_area);
current_y = suggestion_end;
}
}
pub fn render_popup(ai_state: &mut AiState, frame: &mut Frame, input_area: Rect) -> Option<Rect> {
if !ai_state.visible {
return None;
}
let frame_area = frame.area();
let has_suggestions = !ai_state.suggestions.is_empty()
&& ai_state.configured
&& !ai_state.loading
&& ai_state.error.is_none();
let popup_area = if has_suggestions {
let max_content_width = frame_area
.width
.saturating_sub(AUTOCOMPLETE_RESERVED_WIDTH)
.saturating_sub(2 + HORIZONTAL_PADDING * 2);
let content_height =
calculate_suggestions_height(ai_state, max_content_width) + VERTICAL_PADDING * 2;
let area = calculate_popup_area_with_height(frame_area, input_area, content_height)?;
ai_state.previous_popup_height = Some(area.height);
area
} else if let Some(prev_height) = ai_state.previous_popup_height {
calculate_popup_area_with_height(frame_area, input_area, prev_height.saturating_sub(4))
.or_else(|| calculate_popup_area(frame_area, input_area))?
} else {
calculate_popup_area(frame_area, input_area)?
};
popup::clear_area(frame, popup_area);
let title = Line::from(vec![
Span::raw(" "),
Span::styled(&ai_state.provider_name, theme::ai::TITLE),
Span::raw(" "),
]);
let counter = if ai_state.suggestions.len() > 1 {
let current = ai_state
.selection
.get_selected()
.map(|i| i + 1)
.unwrap_or(1);
let total = ai_state.suggestions.len();
Line::from(Span::styled(
format!(" ({}/{}) ", current, total),
Style::default().fg(theme::ai::COUNTER),
))
} else {
Line::default()
};
let counter_width = if ai_state.suggestions.len() > 1 {
let current = ai_state
.selection
.get_selected()
.map(|i| i + 1)
.unwrap_or(1);
let total = ai_state.suggestions.len();
format!(" ({}/{}) ", current, total).len() as u16
} else {
0
};
let max_model_width = (popup_area.width / 2)
.saturating_sub(2)
.saturating_sub(counter_width / 2);
let model_display = if ai_state.model_name.len() > max_model_width as usize {
format!(
"{}...",
&ai_state.model_name[..max_model_width.saturating_sub(3) as usize]
)
} else {
ai_state.model_name.clone()
};
let model_name_title = Line::from(vec![
Span::raw(" "),
Span::styled(model_display, Style::default().fg(theme::ai::MODEL_DISPLAY)),
Span::raw(" "),
]);
let hints = if !ai_state.suggestions.is_empty() {
theme::border_hints::build_hints(
&[
("Alt+1-5", "Apply"),
("Alt+↑↓", "Select"),
("Enter", "Apply Selection"),
("Ctrl+A", "Close"),
],
theme::ai::BORDER,
)
} else {
theme::border_hints::build_hints(&[("Ctrl+A", "Close")], theme::ai::BORDER)
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(title)
.title_top(counter.alignment(ratatui::layout::Alignment::Center))
.title_top(model_name_title.alignment(ratatui::layout::Alignment::Right))
.title_bottom(hints.alignment(ratatui::layout::Alignment::Center))
.border_style(Style::default().fg(theme::ai::BORDER))
.style(Style::default().bg(theme::ai::BACKGROUND));
if has_suggestions {
frame.render_widget(block.clone(), popup_area);
let inner_area = block.inner(popup_area);
let padded_area = popup::inset_rect(inner_area, HORIZONTAL_PADDING, VERTICAL_PADDING);
let max_width = padded_area.width;
render_suggestions_as_widgets(ai_state, frame, padded_area, max_width);
let scrollbar_area = Rect {
x: popup_area.x,
y: popup_area.y.saturating_add(1),
width: popup_area.width,
height: popup_area.height.saturating_sub(2),
};
let total_content_height: usize = ai_state
.selection
.viewport_size()
.saturating_add(ai_state.selection.max_scroll());
let viewport = ai_state.selection.viewport_size();
let max_scroll = ai_state.selection.max_scroll();
let clamped_offset = ai_state.selection.scroll_offset().min(max_scroll);
scrollbar::render_vertical_scrollbar_styled(
frame,
scrollbar_area,
total_content_height,
viewport,
clamped_offset,
theme::ai::SCROLLBAR,
);
} else {
frame.render_widget(block.clone(), popup_area);
let inner_area = block.inner(popup_area);
let padded_area = popup::inset_rect(inner_area, HORIZONTAL_PADDING, VERTICAL_PADDING);
let content = build_content(ai_state, padded_area.width);
let popup_widget = Paragraph::new(content).wrap(Wrap { trim: false });
frame.render_widget(popup_widget, padded_area);
}
Some(popup_area)
}