use crate::commands::{COMMAND_CATALOG, CommandEntry};
pub const MAX_VISIBLE_ROWS: usize = 6;
#[derive(Debug, Default)]
pub struct SlashPopup {
visible: bool,
matches: Vec<CommandEntry>,
cursor: usize,
scroll_top: usize,
dismissed_for: Option<String>,
}
fn slash_filter(input: &str) -> Option<&str> {
if input.contains('\n') {
return None;
}
if input.starts_with('/') {
Some(input)
} else {
None
}
}
impl SlashPopup {
pub fn new() -> Self {
Self::default()
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn matches(&self) -> &[CommandEntry] {
&self.matches
}
pub fn selected(&self) -> Option<CommandEntry> {
self.matches.get(self.cursor).copied()
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn scroll_top(&self) -> usize {
self.scroll_top
}
pub fn hide(&mut self) {
self.reset_visible_state();
self.dismissed_for = None;
}
pub fn dismiss(&mut self, input: &str) {
self.reset_visible_state();
self.dismissed_for = Some(input.to_string());
}
fn reset_visible_state(&mut self) {
self.visible = false;
self.matches.clear();
self.cursor = 0;
self.scroll_top = 0;
}
pub fn sync(&mut self, input: &str) {
if !input.is_empty() {
if let Some(prev) = self.dismissed_for.as_deref() {
if prev == input || input.starts_with(prev) || prev.starts_with(input) {
return;
}
}
}
self.dismissed_for = None;
let Some(prefix) = slash_filter(input) else {
self.hide();
return;
};
let matches = filter_commands(prefix);
if matches.is_empty() {
self.reset_visible_state();
return;
}
self.matches = matches;
self.visible = true;
if self.cursor >= self.matches.len() {
self.cursor = 0;
}
self.ensure_visible();
}
pub fn move_up(&mut self) {
if self.matches.is_empty() {
return;
}
self.cursor = if self.cursor == 0 {
self.matches.len() - 1
} else {
self.cursor - 1
};
self.ensure_visible();
}
pub fn move_down(&mut self) {
if self.matches.is_empty() {
return;
}
self.cursor = if self.cursor + 1 >= self.matches.len() {
0
} else {
self.cursor + 1
};
self.ensure_visible();
}
fn ensure_visible(&mut self) {
let visible = MAX_VISIBLE_ROWS.min(self.matches.len());
if visible == 0 {
self.scroll_top = 0;
return;
}
if self.cursor < self.scroll_top {
self.scroll_top = self.cursor;
} else if self.cursor >= self.scroll_top + visible {
self.scroll_top = self.cursor + 1 - visible;
}
}
}
fn filter_commands(prefix: &str) -> Vec<CommandEntry> {
let lower = prefix.to_lowercase();
COMMAND_CATALOG
.iter()
.copied()
.filter(|entry| entry.name.to_lowercase().starts_with(&lower))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hidden_when_input_is_empty_or_plain_text() {
let mut popup = SlashPopup::new();
popup.sync("");
assert!(!popup.is_visible());
popup.sync("hello world");
assert!(!popup.is_visible());
}
#[test]
fn bare_slash_shows_full_catalog() {
let mut popup = SlashPopup::new();
popup.sync("/");
assert!(popup.is_visible());
assert_eq!(popup.matches().len(), COMMAND_CATALOG.len());
}
#[test]
fn prefix_filters_to_matching_commands() {
let mut popup = SlashPopup::new();
popup.sync("/cl");
assert!(popup.is_visible());
assert!(popup.matches().iter().any(|e| e.name == "/clear"));
assert!(popup.matches().iter().all(|e| e.name.starts_with("/cl")));
}
#[test]
fn slash_with_trailing_space_keeps_popup_open() {
let mut popup = SlashPopup::new();
popup.sync("/e");
assert!(popup.is_visible());
let names: Vec<&str> = popup.matches().iter().map(|e| e.name).collect();
assert!(names.contains(&"/effort"));
}
#[test]
fn hides_when_no_match() {
let mut popup = SlashPopup::new();
popup.sync("/zzzz");
assert!(!popup.is_visible());
}
#[test]
fn matching_is_case_insensitive() {
let mut popup = SlashPopup::new();
popup.sync("/CL");
assert!(popup.matches().iter().any(|e| e.name == "/clear"));
}
#[test]
fn move_down_wraps_around() {
let mut popup = SlashPopup::new();
popup.sync("/");
let total = popup.matches().len();
for _ in 0..total {
popup.move_down();
}
assert_eq!(popup.cursor(), 0);
}
#[test]
fn move_up_from_top_wraps_to_bottom() {
let mut popup = SlashPopup::new();
popup.sync("/");
popup.move_up();
assert_eq!(popup.cursor(), popup.matches().len() - 1);
}
#[test]
fn scroll_top_follows_cursor_past_visible_window() {
let mut popup = SlashPopup::new();
popup.sync("/");
for _ in 0..MAX_VISIBLE_ROWS {
popup.move_down();
}
assert!(popup.scroll_top() > 0);
assert!(popup.cursor() >= popup.scroll_top());
assert!(popup.cursor() < popup.scroll_top() + MAX_VISIBLE_ROWS);
}
#[test]
fn sync_clamps_cursor_when_filter_shrinks_list() {
let mut popup = SlashPopup::new();
popup.sync("/");
for _ in 0..(COMMAND_CATALOG.len() - 1) {
popup.move_down();
}
assert_eq!(popup.cursor(), COMMAND_CATALOG.len() - 1);
popup.sync("/exit");
assert!(popup.is_visible());
assert!(popup.cursor() < popup.matches().len());
}
#[test]
fn second_line_is_ignored_when_first_line_is_not_a_command() {
let mut popup = SlashPopup::new();
popup.sync("hello\n/clear");
assert!(!popup.is_visible());
}
#[test]
fn newline_anywhere_suppresses_the_popup() {
let mut popup = SlashPopup::new();
popup.sync("/clear\nsome more text");
assert!(!popup.is_visible());
}
#[test]
fn slash_with_no_catalog_match_leaves_popup_invisible() {
let mut popup = SlashPopup::new();
popup.sync("/zz-unknown");
assert!(!popup.is_visible());
assert!(popup.matches().is_empty());
}
#[test]
fn dismiss_keeps_popup_hidden_until_input_changes() {
let mut popup = SlashPopup::new();
popup.sync("/c");
assert!(popup.is_visible());
popup.dismiss("/c");
popup.sync("/c");
assert!(!popup.is_visible());
popup.sync("/cl");
assert!(!popup.is_visible());
popup.sync("/");
assert!(!popup.is_visible());
popup.sync("/r");
assert!(popup.is_visible());
}
}