use tracing::debug;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Paragraph, Widget, Wrap},
};
#[derive(Debug, Clone, PartialEq)]
pub enum InputResult {
Submitted(String),
Cancelled,
None,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SelectResult {
Selected(usize),
Cancelled,
None,
}
#[derive(Debug, Clone)]
pub struct InputPrompt {
text: String,
cursor_pos: usize,
placeholder: String,
error: Option<String>,
}
impl InputPrompt {
pub fn new(placeholder: &str) -> Self {
debug!(component = %"InputPrompt", "Component created");
Self {
text: String::new(),
cursor_pos: 0,
placeholder: placeholder.to_string(),
error: None,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> InputResult {
match key.code {
KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE => {
self.insert_char(c);
InputResult::None
}
KeyCode::Backspace => {
self.delete_char_before_cursor();
InputResult::None
}
KeyCode::Delete => {
self.delete_char_at_cursor();
InputResult::None
}
KeyCode::Left => {
self.move_cursor_left();
InputResult::None
}
KeyCode::Right => {
self.move_cursor_right();
InputResult::None
}
KeyCode::Home => {
self.cursor_pos = 0;
InputResult::None
}
KeyCode::End => {
self.cursor_pos = self.text.len();
InputResult::None
}
KeyCode::Enter => {
if self.validate() {
InputResult::Submitted(self.text.clone())
} else {
InputResult::None
}
}
KeyCode::Esc => InputResult::Cancelled,
_ => InputResult::None,
}
}
fn insert_char(&mut self, c: char) {
if self.cursor_pos <= self.text.len() {
self.text.insert(self.cursor_pos, c);
self.cursor_pos += 1;
self.clear_error();
}
}
fn delete_char_before_cursor(&mut self) {
if self.cursor_pos > 0 {
self.cursor_pos -= 1;
self.text.remove(self.cursor_pos);
self.clear_error();
}
}
fn delete_char_at_cursor(&mut self) {
if self.cursor_pos < self.text.len() {
self.text.remove(self.cursor_pos);
self.clear_error();
}
}
fn move_cursor_left(&mut self) {
if self.cursor_pos > 0 {
self.cursor_pos -= 1;
}
}
fn move_cursor_right(&mut self) {
if self.cursor_pos < self.text.len() {
self.cursor_pos += 1;
}
}
fn validate(&self) -> bool {
self.error.is_none()
}
pub fn set_error(&mut self, error: String) {
self.error = Some(error);
}
fn clear_error(&mut self) {
self.error = None;
}
pub fn text(&self) -> &str {
&self.text
}
pub fn cursor_pos(&self) -> usize {
self.cursor_pos
}
pub fn render(&self, area: Rect, buf: &mut Buffer) {
let display_text = if self.text.is_empty() {
Text::from(vec![Line::from(vec![Span::styled(
&self.placeholder,
Style::default().fg(Color::DarkGray),
)])])
} else {
let before_cursor = &self.text[..self.cursor_pos];
let after_cursor = &self.text[self.cursor_pos..];
Text::from(vec![Line::from(vec![
Span::raw(before_cursor),
Span::styled(
if after_cursor.chars().next().is_some() {
after_cursor.chars().next().unwrap().to_string()
} else {
" ".to_string()
},
Style::default().add_modifier(Modifier::REVERSED),
),
Span::raw(&after_cursor[after_cursor.chars().next().map_or(0, |c| c.len_utf8())..]),
])])
};
let paragraph = Paragraph::new(display_text).wrap(Wrap { trim: false });
paragraph.render(area, buf);
if let Some(ref error_msg) = self.error {
let error_area = Rect {
x: area.x,
y: area.y.saturating_add(1),
width: area.width,
height: 1,
};
let error_text = Paragraph::new(Text::from(vec![Line::from(vec![Span::styled(
error_msg,
Style::default().fg(Color::Red),
)])]))
.wrap(Wrap { trim: false });
error_text.render(error_area, buf);
}
}
}
impl Default for InputPrompt {
fn default() -> Self {
Self::new("")
}
}
#[derive(Debug, Clone)]
pub struct SelectPrompt {
options: Vec<String>,
selected: usize,
title: String,
}
impl SelectPrompt {
pub fn new(title: &str, options: Vec<String>) -> Self {
debug!(component = %"SelectPrompt", "Component created");
Self {
options,
selected: 0,
title: title.to_string(),
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> SelectResult {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if self.selected > 0 {
self.selected -= 1;
}
SelectResult::None
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected + 1 < self.options.len() {
self.selected += 1;
}
SelectResult::None
}
KeyCode::PageUp => {
self.selected = self.selected.saturating_sub(5);
SelectResult::None
}
KeyCode::PageDown => {
self.selected = (self.selected + 5).min(self.options.len() - 1);
SelectResult::None
}
KeyCode::Home => {
self.selected = 0;
SelectResult::None
}
KeyCode::End => {
self.selected = self.options.len().saturating_sub(1);
SelectResult::None
}
KeyCode::Enter => SelectResult::Selected(self.selected),
KeyCode::Esc => SelectResult::Cancelled,
_ => SelectResult::None,
}
}
pub fn selected(&self) -> usize {
self.selected
}
pub fn selected_text(&self) -> Option<&str> {
self.options.get(self.selected).map(|s| s.as_str())
}
pub fn options(&self) -> &[String] {
&self.options
}
pub fn title(&self) -> &str {
&self.title
}
pub fn render(&self, area: Rect, buf: &mut Buffer) {
let block = Block::bordered().title(self.title.as_str());
let content_area = block.inner(area);
block.render(area, buf);
for (i, option) in self.options.iter().enumerate() {
if i >= content_area.height as usize {
break;
}
let is_selected = i == self.selected;
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let line = Line::from(vec![
Span::styled(
if is_selected { ">" } else { " " },
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
Span::styled(option, style),
]);
Paragraph::new(Text::from(line)).style(style).render(
Rect {
x: content_area.x,
y: content_area.y.saturating_add(i as u16),
width: content_area.width,
height: 1,
},
buf,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyEvent, KeyEventKind, KeyEventState};
fn create_key_event(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
#[test]
fn test_input_prompt_new() {
let prompt = InputPrompt::new("Enter text:");
assert_eq!(prompt.text(), "");
assert_eq!(prompt.cursor_pos(), 0);
assert_eq!(prompt.placeholder, "Enter text:");
assert!(prompt.error.is_none());
}
#[test]
fn test_input_prompt_default() {
let prompt = InputPrompt::default();
assert_eq!(prompt.text(), "");
assert_eq!(prompt.cursor_pos(), 0);
assert_eq!(prompt.placeholder, "");
}
#[test]
fn test_input_prompt_type() {
let mut prompt = InputPrompt::new("Enter text:");
prompt.handle_key(create_key_event(KeyCode::Char('H')));
assert_eq!(prompt.text(), "H");
assert_eq!(prompt.cursor_pos(), 1);
prompt.handle_key(create_key_event(KeyCode::Char('i')));
assert_eq!(prompt.text(), "Hi");
assert_eq!(prompt.cursor_pos(), 2);
prompt.handle_key(create_key_event(KeyCode::Char('!')));
assert_eq!(prompt.text(), "Hi!");
assert_eq!(prompt.cursor_pos(), 3);
}
#[test]
fn test_input_prompt_backspace() {
let mut prompt = InputPrompt::new("Enter text:");
prompt.handle_key(create_key_event(KeyCode::Char('H')));
prompt.handle_key(create_key_event(KeyCode::Char('i')));
assert_eq!(prompt.text(), "Hi");
assert_eq!(prompt.cursor_pos(), 2);
prompt.handle_key(create_key_event(KeyCode::Backspace));
assert_eq!(prompt.text(), "H");
assert_eq!(prompt.cursor_pos(), 1);
prompt.handle_key(create_key_event(KeyCode::Backspace));
assert_eq!(prompt.text(), "");
assert_eq!(prompt.cursor_pos(), 0);
}
#[test]
fn test_input_prompt_backspace_in_middle() {
let mut prompt = InputPrompt::new("Enter text:");
prompt.text = String::from("Hello");
prompt.cursor_pos = 3;
prompt.handle_key(create_key_event(KeyCode::Backspace));
assert_eq!(prompt.text(), "Helo");
assert_eq!(prompt.cursor_pos(), 2);
}
#[test]
fn test_input_prompt_delete() {
let mut prompt = InputPrompt::new("Enter text:");
prompt.text = String::from("Hello");
prompt.cursor_pos = 2;
prompt.handle_key(create_key_event(KeyCode::Delete));
assert_eq!(prompt.text(), "Helo");
assert_eq!(prompt.cursor_pos(), 2);
}
#[test]
fn test_input_prompt_cursor_move() {
let mut prompt = InputPrompt::new("Enter text:");
prompt.text = String::from("Hello");
prompt.cursor_pos = 5;
prompt.handle_key(create_key_event(KeyCode::Left));
assert_eq!(prompt.cursor_pos(), 4);
prompt.handle_key(create_key_event(KeyCode::Left));
assert_eq!(prompt.cursor_pos(), 3);
prompt.handle_key(create_key_event(KeyCode::Right));
assert_eq!(prompt.cursor_pos(), 4);
prompt.handle_key(create_key_event(KeyCode::Right));
prompt.handle_key(create_key_event(KeyCode::Right));
assert_eq!(prompt.cursor_pos(), 5);
}
#[test]
fn test_input_prompt_home_end() {
let mut prompt = InputPrompt::new("Enter text:");
prompt.text = String::from("Hello");
prompt.cursor_pos = 2;
prompt.handle_key(create_key_event(KeyCode::Home));
assert_eq!(prompt.cursor_pos(), 0);
prompt.handle_key(create_key_event(KeyCode::End));
assert_eq!(prompt.cursor_pos(), 5);
}
#[test]
fn test_input_prompt_submit() {
let mut prompt = InputPrompt::new("Enter text:");
prompt.text = String::from("Test");
let result = prompt.handle_key(create_key_event(KeyCode::Enter));
assert_eq!(result, InputResult::Submitted("Test".to_string()));
}
#[test]
fn test_input_prompt_cancel() {
let mut prompt = InputPrompt::new("Enter text:");
prompt.text = String::from("Test");
let result = prompt.handle_key(create_key_event(KeyCode::Esc));
assert_eq!(result, InputResult::Cancelled);
assert_eq!(prompt.text(), "Test"); }
#[test]
fn test_input_prompt_set_error() {
let mut prompt = InputPrompt::new("Enter text:");
prompt.set_error("Invalid input".to_string());
assert_eq!(prompt.error, Some("Invalid input".to_string()));
assert!(!prompt.validate());
}
#[test]
fn test_input_prompt_clear_error() {
let mut prompt = InputPrompt::new("Enter text:");
prompt.set_error("Invalid input".to_string());
prompt.handle_key(create_key_event(KeyCode::Char('H')));
assert!(prompt.error.is_none());
}
#[test]
fn test_select_prompt_new() {
let options = vec![
"Option 1".to_string(),
"Option 2".to_string(),
"Option 3".to_string(),
];
let prompt = SelectPrompt::new("Choose:", options);
assert_eq!(prompt.title(), "Choose:");
assert_eq!(prompt.selected(), 0);
assert_eq!(prompt.options().len(), 3);
assert_eq!(prompt.selected_text(), Some("Option 1"));
}
#[test]
fn test_select_prompt_navigate_down() {
let options = vec![
"Option 1".to_string(),
"Option 2".to_string(),
"Option 3".to_string(),
];
let mut prompt = SelectPrompt::new("Choose:", options);
prompt.handle_key(create_key_event(KeyCode::Down));
assert_eq!(prompt.selected(), 1);
assert_eq!(prompt.selected_text(), Some("Option 2"));
prompt.handle_key(create_key_event(KeyCode::Down));
assert_eq!(prompt.selected(), 2);
assert_eq!(prompt.selected_text(), Some("Option 3"));
prompt.handle_key(create_key_event(KeyCode::Down));
assert_eq!(prompt.selected(), 2);
}
#[test]
fn test_select_prompt_navigate_up() {
let options = vec![
"Option 1".to_string(),
"Option 2".to_string(),
"Option 3".to_string(),
];
let mut prompt = SelectPrompt::new("Choose:", options);
prompt.selected = 2;
prompt.handle_key(create_key_event(KeyCode::Up));
assert_eq!(prompt.selected(), 1);
prompt.handle_key(create_key_event(KeyCode::Up));
assert_eq!(prompt.selected(), 0);
prompt.handle_key(create_key_event(KeyCode::Up));
assert_eq!(prompt.selected(), 0);
}
#[test]
fn test_select_prompt_vim_keys() {
let options = vec![
"Option 1".to_string(),
"Option 2".to_string(),
"Option 3".to_string(),
];
let mut prompt = SelectPrompt::new("Choose:", options);
prompt.handle_key(create_key_event(KeyCode::Char('j')));
assert_eq!(prompt.selected(), 1);
prompt.handle_key(create_key_event(KeyCode::Char('k')));
assert_eq!(prompt.selected(), 0);
}
#[test]
fn test_select_prompt_select() {
let options = vec![
"Option 1".to_string(),
"Option 2".to_string(),
"Option 3".to_string(),
];
let mut prompt = SelectPrompt::new("Choose:", options);
prompt.selected = 1;
let result = prompt.handle_key(create_key_event(KeyCode::Enter));
assert_eq!(result, SelectResult::Selected(1));
}
#[test]
fn test_select_prompt_cancel() {
let options = vec![
"Option 1".to_string(),
"Option 2".to_string(),
"Option 3".to_string(),
];
let mut prompt = SelectPrompt::new("Choose:", options);
let result = prompt.handle_key(create_key_event(KeyCode::Esc));
assert_eq!(result, SelectResult::Cancelled);
}
#[test]
fn test_select_prompt_page_navigation() {
let options = (0..20).map(|i| format!("Option {}", i)).collect();
let mut prompt = SelectPrompt::new("Choose:", options);
assert_eq!(prompt.selected(), 0);
prompt.handle_key(create_key_event(KeyCode::PageDown));
assert_eq!(prompt.selected(), 5);
prompt.handle_key(create_key_event(KeyCode::PageDown));
assert_eq!(prompt.selected(), 10);
prompt.handle_key(create_key_event(KeyCode::PageUp));
assert_eq!(prompt.selected(), 5);
prompt.selected = 2;
prompt.handle_key(create_key_event(KeyCode::PageUp));
assert_eq!(prompt.selected(), 0);
}
#[test]
fn test_select_prompt_home_end() {
let options = vec![
"Option 1".to_string(),
"Option 2".to_string(),
"Option 3".to_string(),
"Option 4".to_string(),
"Option 5".to_string(),
];
let mut prompt = SelectPrompt::new("Choose:", options);
prompt.selected = 3;
prompt.handle_key(create_key_event(KeyCode::Home));
assert_eq!(prompt.selected(), 0);
prompt.handle_key(create_key_event(KeyCode::End));
assert_eq!(prompt.selected(), 4);
}
#[test]
fn test_select_prompt_empty_options() {
let options: Vec<String> = vec![];
let prompt = SelectPrompt::new("Choose:", options);
assert_eq!(prompt.selected(), 0);
assert!(prompt.selected_text().is_none());
assert_eq!(prompt.options().len(), 0);
}
#[test]
fn test_input_prompt_no_result() {
let mut prompt = InputPrompt::new("Enter text:");
let result = prompt.handle_key(create_key_event(KeyCode::F(1)));
assert_eq!(result, InputResult::None);
}
#[test]
fn test_select_prompt_no_result() {
let options = vec!["Option 1".to_string()];
let mut prompt = SelectPrompt::new("Choose:", options);
let result = prompt.handle_key(create_key_event(KeyCode::F(1)));
assert_eq!(result, SelectResult::None);
}
}