use crate::Theme;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Clear, List, ListItem};
#[derive(Debug, Clone, PartialEq)]
pub enum CompletionKind {
Command,
File,
Directory,
Model,
}
#[derive(Debug, Clone)]
pub struct CompletionItem {
pub label: String,
pub description: Option<String>,
pub insert_text: String,
pub kind: CompletionKind,
}
#[derive(Debug, Default)]
pub struct CompletionState {
pub items: Vec<CompletionItem>,
pub list_state: ratatui::widgets::ListState,
pub visible: bool,
}
impl CompletionState {
pub fn new() -> Self {
Self::default()
}
pub fn set_items(&mut self, items: Vec<CompletionItem>) {
self.items = items;
if self.items.is_empty() {
self.list_state.select(None);
} else {
self.list_state.select(Some(0));
}
}
pub fn selected_item(&self) -> Option<&CompletionItem> {
self.list_state.selected().and_then(|i| self.items.get(i))
}
pub fn select_next(&mut self) {
if self.items.is_empty() {
return;
}
let len = self.items.len();
let current = self.list_state.selected().unwrap_or(0);
let next = (current + 1) % len;
self.list_state.select(Some(next));
}
pub fn select_previous(&mut self) {
if self.items.is_empty() {
return;
}
let len = self.items.len();
let current = self.list_state.selected().unwrap_or(0);
let prev = if current == 0 { len - 1 } else { current - 1 };
self.list_state.select(Some(prev));
}
pub fn show(&mut self) {
self.visible = true;
}
pub fn hide(&mut self) {
self.visible = false;
}
pub fn toggle(&mut self) {
self.visible = !self.visible;
}
pub fn is_visible(&self) -> bool {
self.visible
}
}
pub struct CompletionPopup<'a> {
pub state: &'a mut CompletionState,
pub theme: &'a Theme,
pub max_visible: usize,
}
impl<'a> CompletionPopup<'a> {
pub fn new(state: &'a mut CompletionState, theme: &'a Theme) -> Self {
Self {
state,
theme,
max_visible: 5,
}
}
pub fn render(&mut self, frame: &mut ratatui::Frame, input_area: Rect) -> Option<Rect> {
if !self.state.visible || self.state.items.is_empty() {
return None;
}
let item_count = self.state.items.len().min(self.max_visible);
let available_above = input_area.y as usize;
let popup_height = item_count.min(available_above).min(u16::MAX as usize) as u16;
if popup_height == 0 {
return None;
}
let popup_area = Rect {
x: input_area.x,
y: input_area.y.saturating_sub(popup_height),
width: input_area.width,
height: popup_height,
};
frame.render_widget(Clear, popup_area);
let items: Vec<ListItem> = self
.state
.items
.iter()
.take(self.max_visible)
.map(|item| {
let kind_icon = match item.kind {
CompletionKind::Command => "/",
CompletionKind::File => "📄",
CompletionKind::Directory => "📁",
CompletionKind::Model => "🤖",
};
let label = Span::styled(format!("{} ", item.label), Style::default().bold());
let desc = item.description.as_ref().map(|d| {
Span::styled(
format!(" {}", d),
Style::default().fg(self.theme.colors.muted.to_ratatui()),
)
});
let mut spans = vec![
Span::styled(kind_icon, Style::default()),
Span::raw(" "),
label,
];
if let Some(d) = desc {
spans.push(d);
}
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items)
.block(
Block::bordered().style(Style::default().fg(self.theme.colors.border.to_ratatui())),
)
.highlight_style(
Style::default()
.fg(self.theme.colors.background.to_ratatui())
.bg(self.theme.colors.primary.to_ratatui()),
)
.highlight_symbol("→ ");
frame.render_stateful_widget(list, popup_area, &mut self.state.list_state);
Some(popup_area)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_items() -> Vec<CompletionItem> {
vec![
CompletionItem {
label: "/help".into(),
description: Some("Show help".into()),
insert_text: "/help".into(),
kind: CompletionKind::Command,
},
CompletionItem {
label: "src/main.rs".into(),
description: None,
insert_text: "src/main.rs".into(),
kind: CompletionKind::File,
},
CompletionItem {
label: "target/".into(),
description: Some("directory".into()),
insert_text: "target/".into(),
kind: CompletionKind::Directory,
},
]
}
#[test]
fn new_state_is_hidden() {
let state = CompletionState::new();
assert!(!state.is_visible());
assert!(state.items.is_empty());
}
#[test]
fn show_hide_toggle() {
let mut state = CompletionState::new();
assert!(!state.is_visible());
state.show();
assert!(state.is_visible());
state.hide();
assert!(!state.is_visible());
state.toggle();
assert!(state.is_visible());
state.toggle();
assert!(!state.is_visible());
}
#[test]
fn set_items_resets_selection() {
let mut state = CompletionState::new();
state.set_items(sample_items());
assert_eq!(state.items.len(), 3);
assert_eq!(state.list_state.selected(), Some(0));
assert_eq!(state.selected_item().unwrap().label, "/help");
}
#[test]
fn set_items_clear_selects_none() {
let mut state = CompletionState::new();
state.set_items(sample_items());
state.set_items(vec![]);
assert!(state.items.is_empty());
assert!(state.list_state.selected().is_none());
assert!(state.selected_item().is_none());
}
#[test]
fn select_next_wraps() {
let mut state = CompletionState::new();
state.set_items(sample_items());
assert_eq!(state.list_state.selected(), Some(0));
state.select_next();
assert_eq!(state.list_state.selected(), Some(1));
state.select_next();
assert_eq!(state.list_state.selected(), Some(2));
state.select_next(); assert_eq!(state.list_state.selected(), Some(0));
}
#[test]
fn select_previous_wraps() {
let mut state = CompletionState::new();
state.set_items(sample_items());
assert_eq!(state.list_state.selected(), Some(0));
state.select_previous(); assert_eq!(state.list_state.selected(), Some(2));
state.select_previous();
assert_eq!(state.list_state.selected(), Some(1));
}
#[test]
fn select_navigation_empty_items() {
let mut state = CompletionState::new();
state.select_next(); state.select_previous(); assert!(state.list_state.selected().is_none());
}
}