use std::collections::HashMap; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; use ratatui::widgets::Cell; use ratatui::widgets::Row; use ratatui::widgets::Table; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; const MAX_POPUP_ROWS: usize = 5; /// Ideally this is enough to show the longest command name. const FIRST_COLUMN_WIDTH: u16 = 20; use ratatui::style::Modifier; pub(crate) struct CommandPopup { command_filter: String, all_commands: HashMap<&'static str, SlashCommand>, selected_idx: Option, } impl CommandPopup { pub(crate) fn new() -> Self { Self { command_filter: String::new(), all_commands: built_in_slash_commands(), selected_idx: None, } } /// Update the filter string based on the current composer text. The text /// passed in is expected to start with a leading '/'. Everything after the /// *first* '/" on the *first* line becomes the active filter that is used /// to narrow down the list of available commands. pub(crate) fn on_composer_text_change(&mut self, text: String) { let first_line = text.lines().next().unwrap_or(""); if let Some(stripped) = first_line.strip_prefix('/') { // Extract the *first* token (sequence of non-whitespace // characters) after the slash so that `/clear something` still // shows the help for `/clear`. let token = stripped.trim_start(); let cmd_token = token.split_whitespace().next().unwrap_or(""); // Update the filter keeping the original case (commands are all // lower-case for now but this may change in the future). self.command_filter = cmd_token.to_string(); } else { // The composer no longer starts with '/'. Reset the filter so the // popup shows the *full* command list if it is still displayed // for some reason. self.command_filter.clear(); } // Reset or clamp selected index based on new filtered list. let matches_len = self.filtered_commands().len(); self.selected_idx = match matches_len { 0 => None, _ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)), }; } /// Determine the preferred height of the popup. This is the number of /// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the /// table/border overhead (one line at the top and one at the bottom). pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 { let matches = self.filtered_commands(); let row_count = matches.len().clamp(1, MAX_POPUP_ROWS) as u16; // Account for the border added by the Block that wraps the table. // 2 = one line at the top, one at the bottom. row_count + 2 } /// Return the list of commands that match the current filter. Matching is /// performed using a *prefix* comparison on the command name. fn filtered_commands(&self) -> Vec<&SlashCommand> { let mut cmds: Vec<&SlashCommand> = self .all_commands .values() .filter(|cmd| { if self.command_filter.is_empty() { true } else { cmd.command() .starts_with(&self.command_filter.to_ascii_lowercase()) } }) .collect(); // Sort the commands alphabetically so the order is stable and // predictable. cmds.sort_by(|a, b| a.command().cmp(b.command())); cmds } /// Move the selection cursor one step up. pub(crate) fn move_up(&mut self) { if let Some(len) = self.filtered_commands().len().checked_sub(1) { if len == usize::MAX { return; } } if let Some(idx) = self.selected_idx { if idx > 0 { self.selected_idx = Some(idx - 1); } } else if !self.filtered_commands().is_empty() { self.selected_idx = Some(0); } } /// Move the selection cursor one step down. pub(crate) fn move_down(&mut self) { let matches_len = self.filtered_commands().len(); if matches_len == 0 { self.selected_idx = None; return; } match self.selected_idx { Some(idx) if idx + 1 < matches_len => { self.selected_idx = Some(idx + 1); } None => { self.selected_idx = Some(0); } _ => {} } } /// Return currently selected command, if any. pub(crate) fn selected_command(&self) -> Option<&SlashCommand> { let matches = self.filtered_commands(); self.selected_idx.and_then(|idx| matches.get(idx).copied()) } } impl WidgetRef for CommandPopup { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let matches = self.filtered_commands(); let mut rows: Vec = Vec::new(); let visible_matches: Vec<&SlashCommand> = matches.into_iter().take(MAX_POPUP_ROWS).collect(); if visible_matches.is_empty() { rows.push(Row::new(vec![ Cell::from(""), Cell::from("No matching commands").add_modifier(Modifier::ITALIC), ])); } else { let default_style = Style::default(); let command_style = Style::default().fg(Color::LightBlue); for (idx, cmd) in visible_matches.iter().enumerate() { let (cmd_style, desc_style) = if Some(idx) == self.selected_idx { ( command_style.bg(Color::DarkGray), default_style.bg(Color::DarkGray), ) } else { (command_style, default_style) }; rows.push(Row::new(vec![ Cell::from(format!("/{}", cmd.command())).style(cmd_style), Cell::from(cmd.description().to_string()).style(desc_style), ])); } } use ratatui::layout::Constraint; let table = Table::new( rows, [Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)], ) .column_spacing(0) .block( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded), ); table.render(area, buf); } }