//! Vim-modal input component //! //! Borderless input with vim-like modes (Normal, Insert, Command). //! Uses mode prefix instead of borders for visual indication. use crate::theme::{Theme, VimMode}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ layout::Rect, style::Style, text::{Line, Span}, widgets::Paragraph, Frame, }; /// Input event from the input box #[derive(Debug, Clone)] pub enum InputEvent { /// User submitted a message Message(String), /// User submitted a command (without / prefix) Command(String), /// Mode changed ModeChange(VimMode), /// Request to cancel current operation Cancel, /// Request to expand input (multiline) Expand, } /// Vim-modal input box pub struct InputBox { input: String, cursor_position: usize, history: Vec, history_index: usize, mode: VimMode, theme: Theme, } impl InputBox { pub fn new(theme: Theme) -> Self { Self { input: String::new(), cursor_position: 0, history: Vec::new(), history_index: 0, mode: VimMode::Insert, // Start in insert mode for familiarity theme, } } /// Get current vim mode pub fn mode(&self) -> VimMode { self.mode } /// Set vim mode pub fn set_mode(&mut self, mode: VimMode) { self.mode = mode; } /// Handle key event, returns input event if action is needed pub fn handle_key(&mut self, key: KeyEvent) -> Option { match self.mode { VimMode::Normal => self.handle_normal_mode(key), VimMode::Insert => self.handle_insert_mode(key), VimMode::Command => self.handle_command_mode(key), VimMode::Visual => self.handle_visual_mode(key), } } /// Handle keys in normal mode fn handle_normal_mode(&mut self, key: KeyEvent) -> Option { match key.code { // Enter insert mode KeyCode::Char('i') => { self.mode = VimMode::Insert; Some(InputEvent::ModeChange(VimMode::Insert)) } KeyCode::Char('a') => { self.mode = VimMode::Insert; if self.cursor_position < self.input.len() { self.cursor_position += 1; } Some(InputEvent::ModeChange(VimMode::Insert)) } KeyCode::Char('I') => { self.mode = VimMode::Insert; self.cursor_position = 0; Some(InputEvent::ModeChange(VimMode::Insert)) } KeyCode::Char('A') => { self.mode = VimMode::Insert; self.cursor_position = self.input.len(); Some(InputEvent::ModeChange(VimMode::Insert)) } // Enter command mode KeyCode::Char(':') => { self.mode = VimMode::Command; self.input.clear(); self.cursor_position = 0; Some(InputEvent::ModeChange(VimMode::Command)) } // Navigation KeyCode::Char('h') | KeyCode::Left => { self.cursor_position = self.cursor_position.saturating_sub(1); None } KeyCode::Char('l') | KeyCode::Right => { if self.cursor_position < self.input.len() { self.cursor_position += 1; } None } KeyCode::Char('0') | KeyCode::Home => { self.cursor_position = 0; None } KeyCode::Char('$') | KeyCode::End => { self.cursor_position = self.input.len(); None } KeyCode::Char('w') => { // Jump to next word self.cursor_position = self.next_word_position(); None } KeyCode::Char('b') => { // Jump to previous word self.cursor_position = self.prev_word_position(); None } // Editing KeyCode::Char('x') => { if self.cursor_position < self.input.len() { self.input.remove(self.cursor_position); } None } KeyCode::Char('d') => { // Delete line (dd would require tracking, simplify to clear) self.input.clear(); self.cursor_position = 0; None } // History KeyCode::Char('k') | KeyCode::Up => { self.history_prev(); None } KeyCode::Char('j') | KeyCode::Down => { self.history_next(); None } _ => None, } } /// Handle keys in insert mode fn handle_insert_mode(&mut self, key: KeyEvent) -> Option { match key.code { KeyCode::Esc => { self.mode = VimMode::Normal; // Move cursor back when exiting insert mode (vim behavior) if self.cursor_position > 0 { self.cursor_position -= 1; } Some(InputEvent::ModeChange(VimMode::Normal)) } KeyCode::Enter => { let message = self.input.clone(); if !message.trim().is_empty() { self.history.push(message.clone()); self.history_index = self.history.len(); self.input.clear(); self.cursor_position = 0; return Some(InputEvent::Message(message)); } None } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { Some(InputEvent::Expand) } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { Some(InputEvent::Cancel) } KeyCode::Char(c) => { self.input.insert(self.cursor_position, c); self.cursor_position += 1; None } KeyCode::Backspace => { if self.cursor_position > 0 { self.input.remove(self.cursor_position - 1); self.cursor_position -= 1; } None } KeyCode::Delete => { if self.cursor_position < self.input.len() { self.input.remove(self.cursor_position); } None } KeyCode::Left => { self.cursor_position = self.cursor_position.saturating_sub(1); None } KeyCode::Right => { if self.cursor_position < self.input.len() { self.cursor_position += 1; } None } KeyCode::Home => { self.cursor_position = 0; None } KeyCode::End => { self.cursor_position = self.input.len(); None } KeyCode::Up => { self.history_prev(); None } KeyCode::Down => { self.history_next(); None } _ => None, } } /// Handle keys in command mode fn handle_command_mode(&mut self, key: KeyEvent) -> Option { match key.code { KeyCode::Esc => { self.mode = VimMode::Normal; self.input.clear(); self.cursor_position = 0; Some(InputEvent::ModeChange(VimMode::Normal)) } KeyCode::Enter => { let command = self.input.clone(); self.mode = VimMode::Normal; self.input.clear(); self.cursor_position = 0; if !command.trim().is_empty() { return Some(InputEvent::Command(command)); } Some(InputEvent::ModeChange(VimMode::Normal)) } KeyCode::Char(c) => { self.input.insert(self.cursor_position, c); self.cursor_position += 1; None } KeyCode::Backspace => { if self.cursor_position > 0 { self.input.remove(self.cursor_position - 1); self.cursor_position -= 1; } else { // Empty command, exit to normal mode self.mode = VimMode::Normal; return Some(InputEvent::ModeChange(VimMode::Normal)); } None } KeyCode::Left => { self.cursor_position = self.cursor_position.saturating_sub(1); None } KeyCode::Right => { if self.cursor_position < self.input.len() { self.cursor_position += 1; } None } _ => None, } } /// Handle keys in visual mode (simplified) fn handle_visual_mode(&mut self, key: KeyEvent) -> Option { match key.code { KeyCode::Esc => { self.mode = VimMode::Normal; Some(InputEvent::ModeChange(VimMode::Normal)) } _ => None, } } /// History navigation - previous fn history_prev(&mut self) { if !self.history.is_empty() && self.history_index > 0 { self.history_index -= 1; self.input = self.history[self.history_index].clone(); self.cursor_position = self.input.len(); } } /// History navigation - next fn history_next(&mut self) { if self.history_index < self.history.len().saturating_sub(1) { self.history_index += 1; self.input = self.history[self.history_index].clone(); self.cursor_position = self.input.len(); } else if self.history_index < self.history.len() { self.history_index = self.history.len(); self.input.clear(); self.cursor_position = 0; } } /// Find next word position fn next_word_position(&self) -> usize { let bytes = self.input.as_bytes(); let mut pos = self.cursor_position; // Skip current word while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() { pos += 1; } // Skip whitespace while pos < bytes.len() && bytes[pos].is_ascii_whitespace() { pos += 1; } pos } /// Find previous word position fn prev_word_position(&self) -> usize { let bytes = self.input.as_bytes(); let mut pos = self.cursor_position.saturating_sub(1); // Skip whitespace while pos > 0 && bytes[pos].is_ascii_whitespace() { pos -= 1; } // Skip to start of word while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() { pos -= 1; } pos } /// Render the borderless input (single line) pub fn render(&self, frame: &mut Frame, area: Rect) { let is_empty = self.input.is_empty(); let symbols = &self.theme.symbols; // Mode-specific prefix let prefix = match self.mode { VimMode::Normal => Span::styled( format!("{} ", symbols.mode_normal), self.theme.status_dim, ), VimMode::Insert => Span::styled( format!("{} ", symbols.user_prefix), self.theme.input_prefix, ), VimMode::Command => Span::styled( ": ", self.theme.input_prefix, ), VimMode::Visual => Span::styled( format!("{} ", symbols.mode_visual), self.theme.status_accent, ), }; // Cursor position handling let (text_before, cursor_char, text_after) = if self.cursor_position < self.input.len() { let before = &self.input[..self.cursor_position]; let cursor = &self.input[self.cursor_position..self.cursor_position + 1]; let after = &self.input[self.cursor_position + 1..]; (before, cursor, after) } else { (&self.input[..], " ", "") }; let line = if is_empty && self.mode == VimMode::Insert { Line::from(vec![ Span::raw(" "), prefix, Span::styled("▊", self.theme.input_prefix), Span::styled(" Type message...", self.theme.input_placeholder), ]) } else if is_empty && self.mode == VimMode::Command { Line::from(vec![ Span::raw(" "), prefix, Span::styled("▊", self.theme.input_prefix), ]) } else { // Build cursor span with appropriate styling let cursor_style = if self.mode == VimMode::Normal { Style::default() .bg(self.theme.palette.fg) .fg(self.theme.palette.bg) } else { self.theme.input_prefix }; let cursor_span = if self.mode == VimMode::Normal && !is_empty { Span::styled(cursor_char.to_string(), cursor_style) } else { Span::styled("▊", self.theme.input_prefix) }; Line::from(vec![ Span::raw(" "), prefix, Span::styled(text_before.to_string(), self.theme.input_text), cursor_span, Span::styled(text_after.to_string(), self.theme.input_text), ]) }; let paragraph = Paragraph::new(line); frame.render_widget(paragraph, area); } /// Clear input pub fn clear(&mut self) { self.input.clear(); self.cursor_position = 0; } /// Get current input text pub fn text(&self) -> &str { &self.input } /// Set input text pub fn set_text(&mut self, text: String) { self.input = text; self.cursor_position = self.input.len(); } /// Update theme pub fn set_theme(&mut self, theme: Theme) { self.theme = theme; } } #[cfg(test)] mod tests { use super::*; #[test] fn test_mode_transitions() { let theme = Theme::default(); let mut input = InputBox::new(theme); // Start in insert mode assert_eq!(input.mode(), VimMode::Insert); // Escape to normal mode let event = input.handle_key(KeyEvent::from(KeyCode::Esc)); assert!(matches!(event, Some(InputEvent::ModeChange(VimMode::Normal)))); assert_eq!(input.mode(), VimMode::Normal); // 'i' to insert mode let event = input.handle_key(KeyEvent::from(KeyCode::Char('i'))); assert!(matches!(event, Some(InputEvent::ModeChange(VimMode::Insert)))); assert_eq!(input.mode(), VimMode::Insert); } #[test] fn test_insert_text() { let theme = Theme::default(); let mut input = InputBox::new(theme); input.handle_key(KeyEvent::from(KeyCode::Char('h'))); input.handle_key(KeyEvent::from(KeyCode::Char('i'))); assert_eq!(input.text(), "hi"); } #[test] fn test_command_mode() { let theme = Theme::default(); let mut input = InputBox::new(theme); // Escape to normal, then : to command input.handle_key(KeyEvent::from(KeyCode::Esc)); input.handle_key(KeyEvent::from(KeyCode::Char(':'))); assert_eq!(input.mode(), VimMode::Command); // Type command input.handle_key(KeyEvent::from(KeyCode::Char('q'))); input.handle_key(KeyEvent::from(KeyCode::Char('u'))); input.handle_key(KeyEvent::from(KeyCode::Char('i'))); input.handle_key(KeyEvent::from(KeyCode::Char('t'))); assert_eq!(input.text(), "quit"); // Submit command let event = input.handle_key(KeyEvent::from(KeyCode::Enter)); assert!(matches!(event, Some(InputEvent::Command(cmd)) if cmd == "quit")); } }