//! A single-line editable text field — a one-line sibling of [`code_editor`](crate::code_editor), //! used by [`dialog`](crate::dialog) for its text and list-filter inputs. //! //! Presentation is left to the caller (it reads [`value`](TextField::value) and //! [`display_cursor`](TextField::display_cursor)); the field only owns the text, the cursor, //! and a [`Clipboard`] for paste. No newlines, no selection (yet) — deliberately minimal. use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::clipboard::Clipboard; use crate::text::{byte_of, display_width, next_word, prev_word, super_to_ctrl}; /// A single-line text input with a character-indexed cursor. pub struct TextField { /// The current text (never contains newlines). text: String, /// Cursor position as a character index in `0..=text.chars().count()`. cursor: usize, /// Clipboard for `ctrl`/`⌘`+`v` paste. clipboard: Clipboard, } impl TextField { /// Creates a field pre-filled with `initial`, cursor at the end. pub fn new(initial: impl Into) -> Self { let text = initial.into(); let cursor = text.chars().count(); Self { text, cursor, clipboard: Clipboard::new() } } /// The current text. pub fn value(&self) -> &str { &self.text } /// The cursor's display column (terminal cells from the start), for placing the caret. pub fn display_cursor(&self) -> usize { display_width(&self.text, self.cursor) } /// Number of characters in the field. fn len(&self) -> usize { self.text.chars().count() } /// Handles a key event. Returns `true` if it was consumed (edited or moved the cursor). /// /// Editing keys only: printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves), /// Home/End, and `ctrl`/`⌘`+`v` paste. Esc/Enter/Up/Down are left to the caller. pub fn handle_key(&mut self, key: KeyEvent) -> bool { let key = super_to_ctrl(key); let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let alt = key.modifiers.contains(KeyModifiers::ALT); match key.code { KeyCode::Char('v') if ctrl => self.paste(), KeyCode::Char(_) if ctrl => return false, // other chords aren't ours KeyCode::Char(c) if !alt => self.insert(c), KeyCode::Backspace => return self.backspace(), KeyCode::Delete => return self.delete_forward(), KeyCode::Left if ctrl => self.cursor = prev_word(&self.text, self.cursor), KeyCode::Right if ctrl => self.cursor = next_word(&self.text, self.cursor), KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), KeyCode::Right => self.cursor = (self.cursor + 1).min(self.len()), KeyCode::Home => self.cursor = 0, KeyCode::End => self.cursor = self.len(), _ => return false, } true } fn insert(&mut self, c: char) { let b = byte_of(&self.text, self.cursor); self.text.insert(b, c); self.cursor += 1; } fn backspace(&mut self) -> bool { if self.cursor == 0 { return false; } let b = byte_of(&self.text, self.cursor - 1); self.text.remove(b); self.cursor -= 1; true } fn delete_forward(&mut self) -> bool { if self.cursor >= self.len() { return false; } let b = byte_of(&self.text, self.cursor); self.text.remove(b); true } /// Inserts clipboard text at the cursor, with any newlines stripped (single-line). fn paste(&mut self) { let clean: String = self.clipboard.get().chars().filter(|c| !matches!(c, '\n' | '\r')).collect(); let b = byte_of(&self.text, self.cursor); self.text.insert_str(b, &clean); self.cursor += clean.chars().count(); } } #[cfg(test)] mod tests { use super::*; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) } #[test] fn typing_inserts_and_value_reflects_it() { let mut f = TextField::new(""); for c in "abc".chars() { assert!(f.handle_key(key(KeyCode::Char(c)))); } assert_eq!(f.value(), "abc"); assert_eq!(f.display_cursor(), 3); } #[test] fn backspace_and_delete() { let mut f = TextField::new("abc"); // cursor at end (3) assert!(f.handle_key(key(KeyCode::Backspace))); assert_eq!(f.value(), "ab"); f.handle_key(key(KeyCode::Home)); assert!(f.handle_key(key(KeyCode::Delete))); assert_eq!(f.value(), "b"); // Nothing to delete at the boundaries. assert!(!TextField::new("").handle_key(key(KeyCode::Backspace))); } #[test] fn cursor_movement_inserts_at_right_place() { let mut f = TextField::new("ac"); f.handle_key(key(KeyCode::Left)); // between a and c f.handle_key(key(KeyCode::Char('b'))); assert_eq!(f.value(), "abc"); f.handle_key(key(KeyCode::Home)); f.handle_key(key(KeyCode::Char('X'))); assert_eq!(f.value(), "Xabc"); } #[test] fn ctrl_v_pastes_clipboard_with_newlines_stripped() { let mut f = TextField::new(""); f.clipboard.set("multi\nline".to_string()); assert!(f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL))); assert_eq!(f.value(), "multiline"); } #[test] fn cmd_v_maps_to_ctrl_v() { let mut f = TextField::new(""); f.clipboard.set("hi".to_string()); f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER)); assert_eq!(f.value(), "hi"); } #[test] fn display_cursor_counts_wide_chars() { let mut f = TextField::new("世"); // one double-width char, cursor at end assert_eq!(f.display_cursor(), 2); f.handle_key(key(KeyCode::Home)); assert_eq!(f.display_cursor(), 0); } #[test] fn unhandled_keys_return_false() { let mut f = TextField::new("x"); assert!(!f.handle_key(key(KeyCode::Enter))); assert!(!f.handle_key(key(KeyCode::Up))); assert!(!f.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL))); } }