use crate::app::App;
use crate::app::mention::MAX_VISIBLE;
use crate::app::{mention, slash};
use crate::ui::theme;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use unicode_width::UnicodeWidthChar;
const INPUT_PAD: u16 = 2;
const PROMPT_WIDTH: u16 = 2;
const MAX_WIDTH: u16 = 60;
const MIN_WIDTH: u16 = 20;
const ANCHOR_VERTICAL_GAP: u16 = 1;
const LOGIN_HINT_LINES: u16 = 2;
pub fn is_active(app: &App) -> bool {
app.mention.as_ref().is_some_and(|m| !m.candidates.is_empty())
|| app.slash.as_ref().is_some_and(|s| !s.candidates.is_empty())
}
#[allow(clippy::cast_possible_truncation)]
pub fn compute_height(app: &App) -> u16 {
let count = if let Some(m) = &app.mention {
m.candidates.len()
} else if let Some(s) = &app.slash {
s.candidates.len()
} else {
0
};
if count == 0 {
0
} else {
let visible = count.min(MAX_VISIBLE) as u16;
visible.saturating_add(2) }
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::too_many_lines)]
pub fn render(frame: &mut Frame, input_area: Rect, app: &App) {
enum Dropdown<'a> {
Mention(&'a mention::MentionState),
Slash(&'a slash::SlashState),
}
let dropdown = if let Some(m) = &app.mention {
if m.candidates.is_empty() {
return;
}
Dropdown::Mention(m)
} else if let Some(s) = &app.slash {
if s.candidates.is_empty() {
return;
}
Dropdown::Slash(s)
} else {
return;
};
let height = compute_height(app);
if height == 0 {
return;
}
let text_area = compute_text_area(input_area, app.login_hint.is_some());
if text_area.width == 0 || text_area.height == 0 {
return;
}
let (trigger_row, trigger_col) = match dropdown {
Dropdown::Mention(m) => (m.trigger_row, m.trigger_col),
Dropdown::Slash(s) => (s.trigger_row, s.trigger_col),
};
let (anchor_row, anchor_col) =
wrapped_visual_pos(&app.input.lines, trigger_row, trigger_col, text_area.width);
let mut x = text_area.x.saturating_add(anchor_col).min(text_area.right().saturating_sub(1));
let available_from_x = text_area.right().saturating_sub(x).max(1);
let mut width = available_from_x.min(MAX_WIDTH);
if width < MIN_WIDTH && text_area.width >= MIN_WIDTH {
x = text_area.right().saturating_sub(MIN_WIDTH);
width = MIN_WIDTH;
}
let anchor_y = text_area.y.saturating_add(anchor_row).min(text_area.bottom().saturating_sub(1));
let y = choose_dropdown_y(anchor_y, height, frame.area().y, frame.area().bottom());
let dropdown_area = Rect { x, y, width, height };
let (visible_count, start, end, title) = match dropdown {
Dropdown::Mention(m) => {
let visible_count = m.candidates.len().min(MAX_VISIBLE);
let (start, end) = m.dialog.visible_range(m.candidates.len(), MAX_VISIBLE);
(visible_count, start, end, format!(" Files & Folders ({}) ", m.candidates.len()))
}
Dropdown::Slash(s) => {
let visible_count = s.candidates.len().min(MAX_VISIBLE);
let (start, end) = s.dialog.visible_range(s.candidates.len(), MAX_VISIBLE);
(visible_count, start, end, format!(" Commands ({}) ", s.candidates.len()))
}
};
let mut lines: Vec<Line<'static>> = Vec::with_capacity(visible_count);
match dropdown {
Dropdown::Mention(m) => {
for (i, candidate) in m.candidates[start..end].iter().enumerate() {
let global_idx = start + i;
let is_selected = global_idx == m.dialog.selected;
let mut spans: Vec<Span<'static>> = Vec::new();
if is_selected {
spans.push(Span::styled(
" \u{25b8} ",
Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::raw(" "));
}
let path = &candidate.rel_path;
let query = &m.query;
if query.is_empty() {
spans.push(Span::raw(path.clone()));
} else if let Some(match_start) = path.to_lowercase().find(&query.to_lowercase()) {
let before = &path[..match_start];
let matched = &path[match_start..match_start + query.len()];
let after = &path[match_start + query.len()..];
if !before.is_empty() {
spans.push(Span::raw(before.to_owned()));
}
spans.push(Span::styled(
matched.to_owned(),
Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD),
));
if !after.is_empty() {
spans.push(Span::raw(after.to_owned()));
}
} else {
spans.push(Span::raw(path.clone()));
}
lines.push(Line::from(spans));
}
}
Dropdown::Slash(s) => {
for (i, candidate) in s.candidates[start..end].iter().enumerate() {
let global_idx = start + i;
let is_selected = global_idx == s.dialog.selected;
let mut spans: Vec<Span<'static>> = Vec::new();
if is_selected {
spans.push(Span::styled(
" \u{25b8} ",
Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::raw(" "));
}
let query = &s.query;
let command_name = &candidate.name;
let command_body = command_name.strip_prefix('/').unwrap_or(command_name);
if query.is_empty() {
spans.push(Span::raw(command_name.clone()));
} else if let Some(match_start) =
command_body.to_lowercase().find(&query.to_lowercase())
{
let start_idx = 1 + match_start;
let before = &command_name[..start_idx];
let matched = &command_name[start_idx..start_idx + query.len()];
let after = &command_name[start_idx + query.len()..];
if !before.is_empty() {
spans.push(Span::raw(before.to_owned()));
}
spans.push(Span::styled(
matched.to_owned(),
Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD),
));
if !after.is_empty() {
spans.push(Span::raw(after.to_owned()));
}
} else {
spans.push(Span::raw(command_name.clone()));
}
if !candidate.description.is_empty() {
spans.push(Span::styled(" ", Style::default().fg(theme::DIM)));
spans.push(Span::styled(
candidate.description.clone(),
Style::default().fg(theme::DIM),
));
}
lines.push(Line::from(spans));
}
}
}
let block = Block::default()
.title(Span::styled(title, Style::default().fg(theme::DIM)))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::DIM));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(ratatui::widgets::Clear, dropdown_area);
frame.render_widget(paragraph, dropdown_area);
}
fn compute_text_area(input_area: Rect, has_login_hint: bool) -> Rect {
let input_main_area = if has_login_hint {
let [_hint, main] =
Layout::vertical([Constraint::Length(LOGIN_HINT_LINES), Constraint::Min(1)])
.areas(input_area);
main
} else {
input_area
};
let padded = Rect {
x: input_main_area.x + INPUT_PAD,
y: input_main_area.y,
width: input_main_area.width.saturating_sub(INPUT_PAD * 2),
height: input_main_area.height,
};
let [_prompt_area, text_area] =
Layout::horizontal([Constraint::Length(PROMPT_WIDTH), Constraint::Min(1)]).areas(padded);
text_area
}
#[allow(clippy::cast_possible_truncation)]
fn wrapped_visual_pos(
lines: &[String],
target_row: usize,
target_col: usize,
width: u16,
) -> (u16, u16) {
let width = width as usize;
if width == 0 {
return (0, 0);
}
let mut visual_row: u16 = 0;
for (row, line) in lines.iter().enumerate() {
let mut col_width: usize = 0;
let mut char_idx: usize = 0;
if row == target_row && target_col == 0 {
return (visual_row, 0);
}
for ch in line.chars() {
if row == target_row && char_idx == target_col {
return (visual_row, col_width as u16);
}
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if w > 0 && col_width + w > width && col_width > 0 {
visual_row = visual_row.saturating_add(1);
col_width = 0;
}
if w > width && col_width == 0 {
visual_row = visual_row.saturating_add(1);
char_idx += 1;
continue;
}
if w > 0 {
col_width += w;
}
char_idx += 1;
}
if row == target_row && char_idx == target_col {
if col_width >= width {
return (visual_row.saturating_add(1), 0);
}
return (visual_row, col_width as u16);
}
visual_row = visual_row.saturating_add(1);
}
(visual_row, 0)
}
fn choose_dropdown_y(anchor_y: u16, height: u16, frame_top: u16, frame_bottom: u16) -> u16 {
if height == 0 || frame_bottom <= frame_top {
return frame_top;
}
let below_y = anchor_y.saturating_add(1).saturating_add(ANCHOR_VERTICAL_GAP);
let rows_below_with_gap = frame_bottom.saturating_sub(below_y);
let fits_below_with_gap = height <= rows_below_with_gap;
let above_y = anchor_y.saturating_sub(height.saturating_add(ANCHOR_VERTICAL_GAP));
let rows_above_with_gap =
anchor_y.saturating_sub(frame_top.saturating_add(ANCHOR_VERTICAL_GAP));
let fits_above_with_gap = height <= rows_above_with_gap;
let mut y = if fits_below_with_gap {
below_y
} else if fits_above_with_gap {
above_y
} else if rows_below_with_gap >= rows_above_with_gap {
anchor_y.saturating_add(1)
} else {
anchor_y.saturating_sub(height)
};
let max_y = frame_bottom.saturating_sub(height);
y = y.clamp(frame_top, max_y);
let overlaps_anchor = y <= anchor_y && anchor_y < y.saturating_add(height);
if overlaps_anchor {
let can_place_below = anchor_y.saturating_add(1).saturating_add(height) <= frame_bottom;
let can_place_above = frame_top.saturating_add(height) <= anchor_y;
if can_place_below {
y = anchor_y.saturating_add(1);
} else if can_place_above {
y = anchor_y.saturating_sub(height);
}
}
y.clamp(frame_top, max_y)
}
#[cfg(test)]
mod tests {
use super::choose_dropdown_y;
#[test]
fn dropdown_prefers_below_with_gap_when_space_available() {
let y = choose_dropdown_y(10, 4, 0, 30);
assert_eq!(y, 12);
}
#[test]
fn dropdown_uses_above_with_gap_when_below_too_small() {
let y = choose_dropdown_y(9, 6, 0, 12);
assert_eq!(y, 2);
}
#[test]
fn dropdown_does_not_cover_anchor_row_when_possible() {
let anchor = 5;
let height = 5;
let y = choose_dropdown_y(anchor, height, 0, 11);
assert!(!(y <= anchor && anchor < y + height));
}
}