use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
use crate::command::{CommandEntry, filter_commands};
use crate::theme::Theme;
pub const MAX_VISIBLE: usize = 8;
pub struct SlashAutocompleteState {
pub query: String,
pub selected: usize,
pub filtered: Vec<&'static CommandEntry>,
pub scroll_offset: usize,
}
impl SlashAutocompleteState {
#[must_use]
pub fn new() -> Self {
let mut s = Self {
query: String::new(),
selected: 0,
filtered: Vec::new(),
scroll_offset: 0,
};
s.refilter();
s
}
pub fn push_char(&mut self, c: char) {
self.query.push(c);
self.refilter();
}
pub fn pop_char(&mut self) -> bool {
if self.query.is_empty() {
return true;
}
self.query.pop();
self.refilter();
self.query.is_empty()
}
pub fn move_up(&mut self) {
if self.filtered.is_empty() {
return;
}
if self.selected == 0 {
self.selected = self.filtered.len() - 1;
} else {
self.selected -= 1;
}
self.adjust_scroll();
}
pub fn move_down(&mut self) {
if self.filtered.is_empty() {
return;
}
if self.selected == self.filtered.len() - 1 {
self.selected = 0;
} else {
self.selected += 1;
}
self.adjust_scroll();
}
#[must_use]
pub fn selected_entry(&self) -> Option<&'static CommandEntry> {
self.filtered.get(self.selected).copied()
}
fn refilter(&mut self) {
self.filtered = filter_commands(&self.query);
if self.filtered.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.filtered.len() - 1);
}
self.scroll_offset = self
.scroll_offset
.min(self.filtered.len().saturating_sub(MAX_VISIBLE));
self.adjust_scroll();
}
fn adjust_scroll(&mut self) {
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
} else if self.selected >= self.scroll_offset + MAX_VISIBLE {
self.scroll_offset = self.selected + 1 - MAX_VISIBLE;
}
let max_offset = self.filtered.len().saturating_sub(MAX_VISIBLE);
if self.scroll_offset > max_offset {
self.scroll_offset = max_offset;
}
}
}
impl Default for SlashAutocompleteState {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub fn command_id_to_slash_form(id: &str) -> String {
format!("/{}", id.replace(':', " "))
}
pub fn render(state: &SlashAutocompleteState, frame: &mut Frame, input_area: Rect) {
if state.filtered.is_empty() {
return;
}
let theme = Theme::default();
let visible = state.filtered.len().min(MAX_VISIBLE);
#[allow(clippy::cast_possible_truncation)]
let height = (visible as u16) + 2;
let width: u16 = 60;
let x = if input_area.width > width {
input_area.x + (input_area.width - width) / 2
} else {
input_area.x
};
let actual_width = width.min(input_area.width);
let y = input_area.y.saturating_sub(height);
let popup = Rect {
x,
y,
width: actual_width,
height,
};
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.panel_border)
.title(" Commands ")
.title_alignment(Alignment::Center);
frame.render_widget(block, popup);
let list_area = popup.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
let end = (state.scroll_offset + MAX_VISIBLE).min(state.filtered.len());
let items: Vec<ListItem> = state.filtered[state.scroll_offset..end]
.iter()
.enumerate()
.map(|(i, entry)| {
let abs_i = i + state.scroll_offset;
let style = if abs_i == state.selected {
Style::default().bg(theme.highlight.fg.unwrap_or(ratatui::style::Color::Blue))
} else {
Style::default()
};
let shortcut_str = entry.shortcut.map_or(String::new(), |s| format!(" [{s}]"));
let shortcut_style = style.patch(Style::default().fg(ratatui::style::Color::DarkGray));
ListItem::new(Line::from(vec![
Span::styled(format!("{:<20}", entry.id), style.patch(theme.panel_title)),
Span::styled(format!(" {}", entry.label), style),
Span::styled(shortcut_str, shortcut_style),
]))
})
.collect();
let mut list_state = ListState::default();
list_state.select(Some(state.selected.saturating_sub(state.scroll_offset)));
frame.render_stateful_widget(List::new(items), list_area, &mut list_state);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::render_to_string;
#[test]
fn new_opens_with_all_commands() {
let state = SlashAutocompleteState::new();
let expected = filter_commands("");
assert_eq!(state.filtered.len(), expected.len());
assert_eq!(state.selected, 0);
assert!(state.query.is_empty());
}
#[test]
fn push_char_filters() {
let mut state = SlashAutocompleteState::new();
state.push_char('s');
state.push_char('k');
let expected = filter_commands("sk");
assert_eq!(state.filtered.len(), expected.len());
assert_eq!(state.selected, 0);
}
#[test]
fn command_id_to_slash_form_converts() {
assert_eq!(command_id_to_slash_form("skill:list"), "/skill list");
assert_eq!(command_id_to_slash_form("ingest"), "/ingest");
assert_eq!(command_id_to_slash_form("app:quit"), "/app quit");
}
#[test]
fn pop_char_returns_true_when_empty() {
let mut state = SlashAutocompleteState::new();
assert!(state.pop_char());
}
#[test]
fn pop_char_returns_false_when_query_not_empty_after_pop() {
let mut state = SlashAutocompleteState::new();
state.push_char('s');
state.push_char('k');
assert!(!state.pop_char());
assert_eq!(state.query, "s");
}
#[test]
fn move_down_wraps() {
let mut state = SlashAutocompleteState::new();
assert!(!state.filtered.is_empty());
state.selected = state.filtered.len() - 1;
state.move_down();
assert_eq!(state.selected, 0);
}
#[test]
fn move_up_wraps() {
let mut state = SlashAutocompleteState::new();
assert!(!state.filtered.is_empty());
state.selected = 0;
state.move_up();
assert_eq!(state.selected, state.filtered.len() - 1);
}
#[test]
fn empty_filter_auto_dismisses_signal() {
let mut state = SlashAutocompleteState::new();
for c in "xxxxxxxxxxx".chars() {
state.push_char(c);
}
assert!(state.filtered.is_empty());
}
#[test]
fn render_slash_autocomplete_snapshot() {
let state = SlashAutocompleteState::new();
let output = render_to_string(80, 24, |frame, area| {
render(&state, frame, area);
});
assert!(output.contains("Commands"));
assert!(output.contains("skill:list"));
}
#[test]
fn scroll_offset_adjusts_on_move_down() {
let mut state = SlashAutocompleteState::new();
if state.filtered.len() <= MAX_VISIBLE {
return;
}
for _ in 0..MAX_VISIBLE {
state.move_down();
}
assert!(state.scroll_offset > 0);
}
}