use ratatui::prelude::*;
use ratatui::widgets::{Block, Clear, ListItem, Padding};
use termina::event::{KeyCode, KeyEvent};
use crate::tui::utils::ctrl;
use super::commands::{CommandAction, CommandEntry, TRIGGER_CHARS, default_commands, is_escaped};
use super::list::{render_list_item_line, scroll_offset_for};
use super::search;
use super::theme;
pub const MAX_VISIBLE: usize = 6;
pub enum SlashMessage {
Next,
Prev,
Complete,
Run,
Dismiss,
}
pub struct SlashMenu {
commands: Vec<CommandEntry>,
open: bool,
trigger: char,
filtered: Vec<usize>,
selected: usize,
offset: usize,
dismissed: Option<char>,
}
impl SlashMenu {
pub fn new() -> Self {
Self {
commands: default_commands(),
open: false,
trigger: '/',
filtered: Vec::new(),
selected: 0,
offset: 0,
dismissed: None,
}
}
pub fn active(&self) -> bool {
self.open && !self.filtered.is_empty()
}
pub fn trigger_char(&self) -> char {
self.trigger
}
pub fn filtered_len(&self) -> usize {
self.filtered.len()
}
pub fn popup_rect(&self, history: Rect, input: Rect) -> Rect {
let visible = (self.filtered_len() as u16).min(MAX_VISIBLE as u16).max(1);
let width = input.width.saturating_sub(4).clamp(20, 48);
let height = (visible + 1).min(history.height.max(1));
let y = input.y.saturating_sub(height).max(history.y);
let height = input.y.saturating_sub(y);
Rect::new(input.x + 2, y, width, height)
}
pub fn set_availability(&mut self, action: CommandAction, available: bool) {
for cmd in &mut self.commands {
if cmd.action == action {
cmd.available = available;
}
}
}
pub fn sync(&mut self, buffer: &str) {
match trigger_state(buffer) {
Some((c, query)) => {
if self.dismissed == Some(c) {
self.open = false;
} else {
self.open = true;
self.trigger = c;
self.dismissed = None;
self.refilter(query);
}
}
None => {
self.open = false;
self.dismissed = None;
}
}
}
pub fn dismiss(&mut self) {
if self.open {
self.dismissed = Some(self.trigger);
}
}
fn refilter(&mut self, query: &str) {
self.filtered = search::filter_indices(query, self.commands.len(), |i| {
self.commands[i].action.slash_name().to_string()
})
.into_iter()
.filter(|&i| self.commands[i].available)
.collect();
self.selected = 0;
self.offset = 0;
self.recompute_offset();
}
fn viewport(&self) -> usize {
self.filtered.len().min(MAX_VISIBLE)
}
pub fn next(&mut self) {
if !self.filtered.is_empty() {
self.selected = (self.selected + 1).min(self.filtered.len() - 1);
self.recompute_offset();
}
}
pub fn prev(&mut self) {
if !self.filtered.is_empty() {
self.selected = self.selected.saturating_sub(1);
self.recompute_offset();
}
}
fn recompute_offset(&mut self) {
let vh = self.viewport();
let len = self.filtered.len();
self.offset = scroll_offset_for(self.selected, self.offset, vh, len);
}
pub fn selected_action(&self) -> Option<CommandAction> {
let cmd_idx = self.filtered.get(self.selected)?;
Some(self.commands[*cmd_idx].action)
}
pub fn map_event(&self, key: &KeyEvent) -> Option<SlashMessage> {
if ctrl(key) {
return match key.code {
KeyCode::Char('n') => Some(SlashMessage::Next),
KeyCode::Char('p') => Some(SlashMessage::Prev),
_ => None,
};
}
match key.code {
KeyCode::Tab => Some(SlashMessage::Complete),
KeyCode::Up => Some(SlashMessage::Prev),
KeyCode::Down => Some(SlashMessage::Next),
KeyCode::Enter => Some(SlashMessage::Run),
KeyCode::Escape => Some(SlashMessage::Dismiss),
_ => None,
}
}
pub fn view(&self, frame: &mut Frame<'_>, area: Rect) {
if !self.active() || area.is_empty() {
return;
}
frame.render_widget(Clear, area);
let block = Block::new()
.bg(theme::overlay())
.padding(Padding::new(1, 1, 0, 1));
let inner = block.inner(area);
frame.render_widget(block, area);
let offset = scroll_offset_for(
self.selected,
self.offset,
inner.height as usize,
self.filtered.len(),
);
let visible: Vec<ListItem> = self
.filtered
.iter()
.enumerate()
.skip(offset)
.take(inner.height as usize)
.map(|(idx, &i)| {
let cmd = &self.commands[i];
let line = Line::from(vec![
Span::raw(format!(
"{:<10}",
format!("{}{}", self.trigger, cmd.action.slash_name())
))
.fg(theme::accent())
.bold(),
Span::raw(cmd.description.to_string()).fg(theme::text_muted()),
]);
render_list_item_line(line, idx == self.selected)
})
.collect();
frame.render_widget(ratatui::widgets::List::new(visible), inner);
}
}
fn trigger_state(buffer: &str) -> Option<(char, &str)> {
let first = buffer.chars().next()?;
if !TRIGGER_CHARS.contains(&first) || is_escaped(buffer) {
return None;
}
let query = &buffer[first.len_utf8()..];
if query.chars().any(char::is_whitespace) {
return None;
}
Some((first, query))
}
impl Default for SlashMenu {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn actions(menu: &SlashMenu) -> Vec<CommandAction> {
menu.filtered
.iter()
.map(|&i| menu.commands[i].action)
.collect()
}
#[test]
fn opens_on_trigger_prefix() {
let mut menu = SlashMenu::new();
menu.sync("/");
assert!(menu.active());
assert_eq!(actions(&menu).len(), CommandAction::ALL.len());
menu.sync(":mo");
assert!(menu.active());
assert_eq!(menu.trigger, ':');
assert!(actions(&menu).contains(&CommandAction::OpenModelSelect));
}
#[test]
fn stays_closed_on_escaped_prefix() {
let mut menu = SlashMenu::new();
menu.sync("//");
assert!(!menu.active());
menu.sync("::x");
assert!(!menu.active());
}
#[test]
fn closes_on_whitespace_or_non_trigger() {
let mut menu = SlashMenu::new();
menu.sync("/mo");
assert!(menu.active());
menu.sync("/mo del");
assert!(!menu.open);
menu.sync("hi");
assert!(!menu.open);
menu.sync("");
assert!(!menu.open);
}
#[test]
fn esc_dismisses_until_trigger_cleared() {
let mut menu = SlashMenu::new();
menu.sync("/mo");
assert!(menu.active());
menu.dismiss();
menu.sync("/mod");
assert!(!menu.open, "dismissed state persists while typing");
menu.sync("/m");
assert!(!menu.open);
menu.sync("hi");
assert!(!menu.open);
menu.sync("/");
assert!(menu.active(), "clearing the trigger re-arms the menu");
menu.sync(":n");
assert!(menu.active(), "the other trigger opens the menu");
}
#[test]
fn filters_by_query_and_availability() {
let mut menu = SlashMenu::new();
menu.set_availability(CommandAction::UndoLastTurn, false);
menu.sync("/re");
let acts = actions(&menu);
assert!(acts.contains(&CommandAction::Replay));
assert!(acts.contains(&CommandAction::Reload));
menu.sync(":undo");
assert!(
actions(&menu).is_empty(),
"unavailable commands stay hidden"
);
menu.set_availability(CommandAction::UndoLastTurn, true);
menu.sync(":undo");
assert!(actions(&menu).contains(&CommandAction::UndoLastTurn));
menu.sync("/zzz");
assert!(menu.filtered.is_empty());
assert!(!menu.active());
}
#[test]
fn selection_moves_and_wraps_clamped() {
let mut menu = SlashMenu::new();
menu.sync("/");
menu.next();
menu.next();
assert_eq!(menu.selected, 2);
menu.prev();
assert_eq!(menu.selected, 1);
for _ in 0..10 {
menu.prev();
}
assert_eq!(menu.selected, 0);
for _ in 0..20 {
menu.next();
}
assert_eq!(menu.selected, menu.filtered.len() - 1);
}
#[test]
fn selected_action_resolves() {
let mut menu = SlashMenu::new();
menu.sync(":se");
assert_eq!(
menu.selected_action(),
Some(CommandAction::OpenSessionPicker)
);
}
}