use crate::KeyCode;
use crate::hook::KeyEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PromptPlacement {
#[default]
BottomBar,
TopPalette,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptItem {
pub label: String,
pub hint: Option<String>,
}
impl PromptItem {
pub fn new(label: &str) -> Self {
Self {
label: label.to_string(),
hint: None,
}
}
pub fn with_hint(label: &str, hint: &str) -> Self {
Self {
label: label.to_string(),
hint: Some(hint.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptSpec {
pub id: &'static str,
pub prefix: String,
pub placeholder: String,
pub placement: PromptPlacement,
pub live_update: bool,
}
impl PromptSpec {
pub fn new(
id: &'static str,
prefix: &str,
placeholder: &str,
placement: PromptPlacement,
live_update: bool,
) -> Self {
Self {
id,
prefix: prefix.to_string(),
placeholder: placeholder.to_string(),
placement,
live_update,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromptAction {
Editing,
Submitted(String),
Cancelled,
Ignored,
}
const HISTORY_LIMIT: usize = 100;
#[derive(Debug, Clone, Default)]
pub struct PromptState {
spec: Option<PromptSpec>,
input: String,
cursor: usize,
items: Vec<PromptItem>,
selected: usize,
message: Option<String>,
history: Vec<String>,
history_idx: Option<usize>,
draft: String,
}
impl PromptState {
pub fn new() -> Self {
Self::default()
}
pub fn is_open(&self) -> bool {
self.spec.is_some()
}
pub fn spec(&self) -> Option<&PromptSpec> {
self.spec.as_ref()
}
pub fn input(&self) -> &str {
&self.input
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn items(&self) -> &[PromptItem] {
&self.items
}
pub fn selected_index(&self) -> usize {
self.selected
}
pub fn selected_item(&self) -> Option<&PromptItem> {
self.items.get(self.selected)
}
pub fn message(&self) -> Option<&str> {
self.message.as_deref()
}
pub fn history(&self) -> &[String] {
&self.history
}
pub fn open(&mut self, spec: PromptSpec, initial: &str) {
self.spec = Some(spec);
self.input = initial.to_string();
self.cursor = self.input.len();
self.items.clear();
self.selected = 0;
self.message = None;
self.history_idx = None;
self.draft.clear();
}
pub fn close(&mut self) {
self.spec = None;
self.input.clear();
self.cursor = 0;
self.items.clear();
self.selected = 0;
self.message = None;
self.history_idx = None;
self.draft.clear();
}
pub fn set_message(&mut self, message: &str) {
self.message = Some(message.to_string());
}
pub fn clear_message(&mut self) {
self.message = None;
}
pub fn set_items(&mut self, items: Vec<PromptItem>) {
self.items = items;
self.selected = self.selected.min(self.items.len().saturating_sub(1));
}
fn prev_boundary(&self, idx: usize) -> usize {
let mut pos = idx.min(self.input.len());
if pos == 0 {
return 0;
}
pos -= 1;
while pos > 0 && !self.input.is_char_boundary(pos) {
pos -= 1;
}
pos
}
fn next_boundary(&self, idx: usize) -> usize {
let mut pos = idx.min(self.input.len());
if pos >= self.input.len() {
return self.input.len();
}
pos += 1;
while pos < self.input.len() && !self.input.is_char_boundary(pos) {
pos += 1;
}
pos
}
fn abandon_history(&mut self) {
self.history_idx = None;
self.draft.clear();
}
fn backspace_one(&mut self) {
if self.cursor == 0 {
return;
}
let prev = self.prev_boundary(self.cursor);
self.input.drain(prev..self.cursor);
self.cursor = prev;
}
pub fn insert(&mut self, text: &str) {
self.abandon_history();
self.input.insert_str(self.cursor, text);
self.cursor += text.len();
}
pub fn backspace(&mut self) {
self.abandon_history();
self.backspace_one();
}
pub fn delete_after_cursor(&mut self) {
self.abandon_history();
if self.cursor < self.input.len() {
let next = self.next_boundary(self.cursor);
self.input.drain(self.cursor..next);
}
}
pub fn delete_word_before(&mut self) {
self.abandon_history();
while self.cursor > 0
&& self.input[..self.cursor]
.chars()
.next_back()
.is_some_and(|c| c.is_whitespace())
{
self.backspace_one();
}
while self.cursor > 0
&& self.input[..self.cursor]
.chars()
.next_back()
.is_some_and(|c| !c.is_whitespace())
{
self.backspace_one();
}
}
pub fn delete_word_after(&mut self) {
self.abandon_history();
while self.cursor < self.input.len()
&& self.input[self.cursor..]
.chars()
.next()
.is_some_and(|c| c.is_whitespace())
{
self.delete_after_cursor();
}
while self.cursor < self.input.len()
&& self.input[self.cursor..]
.chars()
.next()
.is_some_and(|c| !c.is_whitespace())
{
self.delete_after_cursor();
}
}
pub fn clear_to_start(&mut self) {
self.abandon_history();
self.input.drain(..self.cursor);
self.cursor = 0;
}
pub fn clear_to_end(&mut self) {
self.abandon_history();
self.input.truncate(self.cursor);
}
pub fn move_word_left(&mut self) {
while self.cursor > 0
&& self.input[..self.cursor]
.chars()
.next_back()
.is_some_and(|c| c.is_whitespace())
{
self.cursor = self.prev_boundary(self.cursor);
}
while self.cursor > 0
&& self.input[..self.cursor]
.chars()
.next_back()
.is_some_and(|c| !c.is_whitespace())
{
self.cursor = self.prev_boundary(self.cursor);
}
}
pub fn move_word_right(&mut self) {
while self.cursor < self.input.len()
&& self.input[self.cursor..]
.chars()
.next()
.is_some_and(|c| c.is_whitespace())
{
self.cursor = self.next_boundary(self.cursor);
}
while self.cursor < self.input.len()
&& self.input[self.cursor..]
.chars()
.next()
.is_some_and(|c| !c.is_whitespace())
{
self.cursor = self.next_boundary(self.cursor);
}
}
pub fn move_left(&mut self) {
self.cursor = self.prev_boundary(self.cursor);
}
pub fn move_right(&mut self) {
self.cursor = self.next_boundary(self.cursor);
}
pub fn move_home(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = self.input.len();
}
pub fn history_prev(&mut self) {
if self.history.is_empty() {
return;
}
let idx = match self.history_idx {
None => {
self.draft = self.input.clone();
self.history.len() - 1
}
Some(0) => return,
Some(i) => i - 1,
};
self.history_idx = Some(idx);
self.input = self.history[idx].clone();
self.cursor = self.input.len();
}
pub fn history_next(&mut self) {
let idx = match self.history_idx {
None => return,
Some(i) => i,
};
if idx + 1 < self.history.len() {
self.history_idx = Some(idx + 1);
self.input = self.history[idx + 1].clone();
} else {
self.history_idx = None;
self.input = std::mem::take(&mut self.draft);
}
self.cursor = self.input.len();
}
pub fn select_next(&mut self) {
if self.items.is_empty() {
return;
}
self.selected = (self.selected + 1) % self.items.len();
}
pub fn select_prev(&mut self) {
if self.items.is_empty() {
return;
}
self.selected = self.selected.checked_sub(1).unwrap_or(self.items.len() - 1);
}
pub fn complete_selected(&mut self) {
if let Some(item) = self.selected_item() {
let label = item.label.clone();
self.abandon_history();
self.input = label;
self.cursor = self.input.len();
}
}
pub fn submit(&mut self) -> String {
let submitted = self.input.clone();
if !submitted.is_empty() && self.history.last() != Some(&submitted) {
self.history.push(submitted.clone());
if self.history.len() > HISTORY_LIMIT {
let excess = self.history.len() - HISTORY_LIMIT;
self.history.drain(..excess);
}
}
self.history_idx = None;
self.draft.clear();
submitted
}
pub fn handle_key(&mut self, event: &KeyEvent) -> PromptAction {
if self.spec.is_none() {
return PromptAction::Ignored;
}
let mods = &event.modifiers;
match &event.code {
KeyCode::Escape => {
self.close();
PromptAction::Cancelled
}
KeyCode::Enter if !mods.ctrl && !mods.alt && !mods.meta => {
PromptAction::Submitted(self.submit())
}
KeyCode::Tab => {
if self.selected_item().is_some() {
self.complete_selected();
PromptAction::Editing
} else {
PromptAction::Ignored
}
}
KeyCode::Up if !mods.ctrl && !mods.alt && !mods.meta => {
if self.items.is_empty() {
self.history_prev();
} else {
self.select_prev();
}
PromptAction::Editing
}
KeyCode::Down if !mods.ctrl && !mods.alt && !mods.meta => {
if self.items.is_empty() {
self.history_next();
} else {
self.select_next();
}
PromptAction::Editing
}
KeyCode::Backspace if (mods.ctrl || mods.alt) && !mods.meta => {
self.delete_word_before();
PromptAction::Editing
}
KeyCode::Backspace if mods.meta && !mods.ctrl && !mods.alt => {
self.clear_to_start();
PromptAction::Editing
}
KeyCode::Backspace if !mods.ctrl && !mods.alt && !mods.meta => {
self.backspace();
PromptAction::Editing
}
KeyCode::Delete if (mods.ctrl || mods.alt) && !mods.meta => {
self.delete_word_after();
PromptAction::Editing
}
KeyCode::Delete if !mods.ctrl && !mods.alt && !mods.meta => {
self.delete_after_cursor();
PromptAction::Editing
}
KeyCode::Left if (mods.ctrl || mods.alt) && !mods.meta => {
self.move_word_left();
PromptAction::Editing
}
KeyCode::Left if mods.meta && !mods.ctrl && !mods.alt => {
self.move_home();
PromptAction::Editing
}
KeyCode::Left if !mods.ctrl && !mods.alt && !mods.meta => {
self.move_left();
PromptAction::Editing
}
KeyCode::Right if (mods.ctrl || mods.alt) && !mods.meta => {
self.move_word_right();
PromptAction::Editing
}
KeyCode::Right if mods.meta && !mods.ctrl && !mods.alt => {
self.move_end();
PromptAction::Editing
}
KeyCode::Right if !mods.ctrl && !mods.alt && !mods.meta => {
self.move_right();
PromptAction::Editing
}
KeyCode::Home => {
self.move_home();
PromptAction::Editing
}
KeyCode::End => {
self.move_end();
PromptAction::Editing
}
KeyCode::Char(c) if mods.ctrl && !mods.alt && !mods.meta => {
match c.to_ascii_lowercase() {
'u' => {
self.clear_to_start();
PromptAction::Editing
}
'k' => {
self.clear_to_end();
PromptAction::Editing
}
'w' => {
self.delete_word_before();
PromptAction::Editing
}
'a' => {
self.move_home();
PromptAction::Editing
}
'e' => {
self.move_end();
PromptAction::Editing
}
_ => PromptAction::Ignored,
}
}
KeyCode::Char(c) if !mods.ctrl && !mods.alt && !mods.meta => {
self.insert(&c.to_string());
PromptAction::Editing
}
_ => PromptAction::Ignored,
}
}
}
pub fn fuzzy_score(candidate: &str, query: &str) -> Option<i64> {
if query.is_empty() {
return Some(0);
}
let lowered: Vec<char> = candidate.to_lowercase().chars().collect();
let wanted: Vec<char> = query.to_lowercase().chars().collect();
let mut score: i64 = 0;
let mut pos = 0;
let mut prev: Option<usize> = None;
for (qi, qc) in wanted.iter().enumerate() {
let mut found = None;
for (ci, cc) in lowered.iter().enumerate().skip(pos) {
if cc == qc {
found = Some(ci);
break;
}
}
let ci = found?;
score += 10;
if qi == 0 && ci == 0 {
score += 20;
}
if prev.is_some_and(|p| p + 1 == ci) {
score += 15;
}
prev = Some(ci);
pos = ci + 1;
}
Some(score - lowered.len() as i64)
}
pub fn fuzzy_filter(candidates: &[PromptItem], query: &str) -> Vec<(usize, i64)> {
let mut ranked: Vec<(usize, i64)> = candidates
.iter()
.enumerate()
.filter_map(|(i, item)| fuzzy_score(&item.label, query).map(|s| (i, s)))
.collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
ranked
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hook::Modifiers;
use crate::keycode::KeyCode;
fn search_spec() -> PromptSpec {
PromptSpec::new("search", "/", "Search", PromptPlacement::BottomBar, true)
}
fn palette_spec() -> PromptSpec {
PromptSpec::new(
"commands",
"",
"Type a command",
PromptPlacement::TopPalette,
false,
)
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::plain(code)
}
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
}
}
fn alt(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: Modifiers {
alt: true,
..Default::default()
},
}
}
#[test]
fn open_close_lifecycle() {
let mut prompt = PromptState::new();
assert!(!prompt.is_open());
assert_eq!(
prompt.handle_key(&key(KeyCode::Char('a'))),
PromptAction::Ignored
);
prompt.open(search_spec(), "init");
assert!(prompt.is_open());
assert_eq!(prompt.spec().unwrap().id, "search");
assert_eq!(prompt.input(), "init");
assert_eq!(prompt.cursor(), 4);
prompt.close();
assert!(!prompt.is_open());
assert_eq!(prompt.input(), "");
assert_eq!(prompt.cursor(), 0);
assert!(prompt.message().is_none());
}
#[test]
fn typing_and_cursor_movement() {
let mut prompt = PromptState::new();
prompt.open(search_spec(), "");
prompt.insert("hello");
assert_eq!(prompt.input(), "hello");
assert_eq!(prompt.cursor(), 5);
prompt.move_left();
prompt.move_left();
prompt.insert("X");
assert_eq!(prompt.input(), "helXlo");
prompt.move_home();
assert_eq!(prompt.cursor(), 0);
prompt.move_end();
assert_eq!(prompt.cursor(), 6);
}
#[test]
fn editing_is_unicode_safe() {
let mut prompt = PromptState::new();
prompt.open(search_spec(), "");
prompt.insert("héllo");
assert_eq!(prompt.cursor(), "héllo".len());
prompt.move_home();
prompt.move_right();
assert_eq!(prompt.cursor(), 1);
prompt.move_right();
assert_eq!(prompt.cursor(), 3);
prompt.backspace();
assert_eq!(prompt.input(), "hllo");
assert!(prompt.input().is_char_boundary(prompt.cursor()));
}
#[test]
fn backspace_delete_word_and_clear_line() {
let mut prompt = PromptState::new();
prompt.open(search_spec(), "");
prompt.insert("foo bar baz");
prompt.delete_word_before();
assert_eq!(prompt.input(), "foo bar ");
prompt.delete_word_before();
assert_eq!(prompt.input(), "foo ");
prompt.clear_to_start();
assert_eq!(prompt.input(), "");
assert_eq!(prompt.cursor(), 0);
}
#[test]
fn enter_submits_and_records_history() {
let mut prompt = PromptState::new();
prompt.open(search_spec(), "");
prompt.insert("first");
assert_eq!(
prompt.handle_key(&key(KeyCode::Enter)),
PromptAction::Submitted("first".to_string())
);
assert!(prompt.is_open());
assert_eq!(prompt.history(), &["first".to_string()]);
prompt.close();
prompt.open(search_spec(), "");
prompt.insert("second");
prompt.submit();
prompt.close();
assert_eq!(
prompt.history(),
&["first".to_string(), "second".to_string()]
);
prompt.open(search_spec(), "");
prompt.history_prev();
assert_eq!(prompt.input(), "second");
prompt.history_prev();
assert_eq!(prompt.input(), "first");
prompt.history_next();
assert_eq!(prompt.input(), "second");
prompt.history_next();
assert_eq!(prompt.input(), "");
}
#[test]
fn escape_cancels_and_closes() {
let mut prompt = PromptState::new();
prompt.open(search_spec(), "");
prompt.insert("abc");
assert_eq!(
prompt.handle_key(&key(KeyCode::Escape)),
PromptAction::Cancelled
);
assert!(!prompt.is_open());
assert_eq!(prompt.input(), "");
}
#[test]
fn handle_key_routes_editing_keys() {
let mut prompt = PromptState::new();
prompt.open(search_spec(), "");
assert_eq!(
prompt.handle_key(&key(KeyCode::Char('a'))),
PromptAction::Editing
);
assert_eq!(prompt.input(), "a");
assert_eq!(
prompt.handle_key(&key(KeyCode::Backspace)),
PromptAction::Editing
);
assert_eq!(prompt.input(), "");
assert_eq!(
prompt.handle_key(&key(KeyCode::Left)),
PromptAction::Editing
);
assert_eq!(
prompt.handle_key(&key(KeyCode::Char(' '))),
PromptAction::Editing
);
assert_eq!(prompt.input(), " ");
}
#[test]
fn ctrl_shortcuts_edit_input() {
let mut prompt = PromptState::new();
prompt.open(search_spec(), "");
prompt.insert("hello");
assert_eq!(
prompt.handle_key(&ctrl(KeyCode::Char('a'))),
PromptAction::Editing
);
assert_eq!(prompt.cursor(), 0);
assert_eq!(
prompt.handle_key(&ctrl(KeyCode::Char('e'))),
PromptAction::Editing
);
assert_eq!(prompt.cursor(), 5);
assert_eq!(
prompt.handle_key(&ctrl(KeyCode::Char('u'))),
PromptAction::Editing
);
assert_eq!(prompt.input(), "");
}
#[test]
fn word_movement_and_deletion_shortcuts() {
let mut prompt = PromptState::new();
prompt.open(search_spec(), "");
prompt.insert("hello beautiful world");
assert_eq!(
prompt.handle_key(&ctrl(KeyCode::Left)),
PromptAction::Editing
);
assert_eq!(prompt.cursor(), 16);
assert_eq!(
prompt.handle_key(&ctrl(KeyCode::Left)),
PromptAction::Editing
);
assert_eq!(prompt.cursor(), 6);
assert_eq!(
prompt.handle_key(&ctrl(KeyCode::Right)),
PromptAction::Editing
);
assert_eq!(prompt.cursor(), 15);
assert_eq!(
prompt.handle_key(&ctrl(KeyCode::Delete)),
PromptAction::Editing
);
assert_eq!(prompt.input(), "hello beautiful");
assert_eq!(
prompt.handle_key(&ctrl(KeyCode::Backspace)),
PromptAction::Editing
);
assert_eq!(prompt.input(), "hello ");
prompt.insert("world");
assert_eq!(
prompt.handle_key(&alt(KeyCode::Backspace)),
PromptAction::Editing
);
assert_eq!(prompt.input(), "hello ");
prompt.move_home();
prompt.insert("new ");
prompt.move_home();
assert_eq!(
prompt.handle_key(&ctrl(KeyCode::Char('k'))),
PromptAction::Editing
);
assert_eq!(prompt.input(), "");
}
#[test]
fn items_selection_wraps_and_clamps() {
let mut prompt = PromptState::new();
prompt.open(palette_spec(), "");
prompt.set_items(vec![
PromptItem::new("save"),
PromptItem::new("quit"),
PromptItem::new("write"),
]);
assert_eq!(prompt.selected_index(), 0);
prompt.select_prev();
assert_eq!(prompt.selected_index(), 2);
prompt.select_next();
assert_eq!(prompt.selected_index(), 0);
assert_eq!(prompt.selected_item().unwrap().label, "save");
prompt.set_items(vec![PromptItem::new("only")]);
assert_eq!(prompt.selected_index(), 0);
prompt.set_items(vec![]);
assert!(prompt.selected_item().is_none());
}
#[test]
fn tab_completes_selected_label() {
let mut prompt = PromptState::new();
prompt.open(palette_spec(), "");
prompt.set_items(vec![PromptItem::with_hint("save-file", "Ctrl+S")]);
assert_eq!(prompt.handle_key(&key(KeyCode::Tab)), PromptAction::Editing);
assert_eq!(prompt.input(), "save-file");
}
#[test]
fn up_down_prefer_items_over_history() {
let mut prompt = PromptState::new();
prompt.open(palette_spec(), "");
prompt.insert("x");
prompt.submit();
prompt.insert("y");
assert_eq!(prompt.handle_key(&key(KeyCode::Up)), PromptAction::Editing);
assert_eq!(prompt.input(), "x");
prompt.set_items(vec![PromptItem::new("one"), PromptItem::new("two")]);
assert_eq!(
prompt.handle_key(&key(KeyCode::Down)),
PromptAction::Editing
);
assert_eq!(prompt.selected_index(), 1);
assert_eq!(prompt.input(), "x");
}
#[test]
fn arrow_key_aliases_move_cursor() {
let mut prompt = PromptState::new();
prompt.open(search_spec(), "");
prompt.insert("ab");
assert_eq!(
prompt.handle_key(&key(KeyCode::Left)),
PromptAction::Editing
);
assert_eq!(prompt.cursor(), 1);
assert_eq!(
prompt.handle_key(&key(KeyCode::Left)),
PromptAction::Editing
);
assert_eq!(prompt.cursor(), 0);
assert_eq!(
prompt.handle_key(&key(KeyCode::Right)),
PromptAction::Editing
);
assert_eq!(prompt.cursor(), 1);
prompt.set_items(vec![PromptItem::new("one"), PromptItem::new("two")]);
assert_eq!(prompt.handle_key(&key(KeyCode::Up)), PromptAction::Editing);
assert_eq!(prompt.selected_index(), 1);
assert_eq!(
prompt.handle_key(&key(KeyCode::Down)),
PromptAction::Editing
);
assert_eq!(prompt.selected_index(), 0);
}
#[test]
fn message_roundtrip() {
let mut prompt = PromptState::new();
prompt.open(search_spec(), "");
assert!(prompt.message().is_none());
prompt.set_message("E486: Pattern not found");
assert_eq!(prompt.message(), Some("E486: Pattern not found"));
prompt.clear_message();
assert!(prompt.message().is_none());
}
#[test]
fn fuzzy_score_matches_subsequence_case_insensitively() {
assert!(fuzzy_score("save-file", "satek").is_none());
assert!(fuzzy_score("Save-File", "sf").is_some());
assert!(fuzzy_score("quit", "sf").is_none());
let strong = fuzzy_score("save", "sa").unwrap();
let weak = fuzzy_score("xsave", "sa").unwrap();
assert!(strong > weak);
assert_eq!(fuzzy_score("anything", ""), Some(0));
}
#[test]
fn fuzzy_filter_sorts_by_score_then_index() {
let items = vec![
PromptItem::new("quit"),
PromptItem::new("save-file"),
PromptItem::new("save-all"),
];
let ranked = fuzzy_filter(&items, "save");
let ids: Vec<usize> = ranked.iter().map(|(i, _)| *i).collect();
assert_eq!(ids, vec![2, 1]);
let tied = vec![PromptItem::new("abx"), PromptItem::new("aby")];
let ranked = fuzzy_filter(&tied, "ab");
let ids: Vec<usize> = ranked.iter().map(|(i, _)| *i).collect();
assert_eq!(ids, vec![0, 1]);
}
}