use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[derive(Debug, Clone, Default)]
pub struct TextField {
value: String,
cursor: usize,
}
impl TextField {
pub fn new() -> Self {
Self::default()
}
pub fn with_value(value: impl Into<String>) -> Self {
let value = value.into();
let cursor = value.chars().count();
Self { value, cursor }
}
pub fn value(&self) -> &str {
&self.value
}
pub fn trimmed(&self) -> &str {
self.value.trim()
}
pub fn is_blank(&self) -> bool {
self.value.trim().is_empty()
}
#[allow(dead_code)]
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn len_chars(&self) -> usize {
self.value.chars().count()
}
pub fn clear(&mut self) {
self.value.clear();
self.cursor = 0;
}
fn byte_at(&self, idx: usize) -> usize {
self.value
.char_indices()
.nth(idx)
.map_or(self.value.len(), |(b, _)| b)
}
pub fn insert(&mut self, c: char) {
let at = self.byte_at(self.cursor);
self.value.insert(at, c);
self.cursor += 1;
}
pub fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let at = self.byte_at(self.cursor - 1);
self.value.remove(at);
self.cursor -= 1;
}
pub fn delete(&mut self) {
if self.cursor >= self.len_chars() {
return;
}
let at = self.byte_at(self.cursor);
self.value.remove(at);
}
pub fn delete_word_before(&mut self) {
let chars: Vec<char> = self.value.chars().collect();
let mut i = self.cursor;
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
let start = self.byte_at(i);
let end = self.byte_at(self.cursor);
self.value.replace_range(start..end, "");
self.cursor = i;
}
pub fn delete_to_end(&mut self) {
let at = self.byte_at(self.cursor);
self.value.truncate(at);
}
pub fn left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn right(&mut self) {
self.cursor = (self.cursor + 1).min(self.len_chars());
}
pub fn home(&mut self) {
self.cursor = 0;
}
pub fn end(&mut self) {
self.cursor = self.len_chars();
}
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('a') if ctrl => self.home(),
KeyCode::Char('e') if ctrl => self.end(),
KeyCode::Char('u') if ctrl => {
let at = self.byte_at(self.cursor);
self.value.replace_range(..at, "");
self.cursor = 0;
}
KeyCode::Char('k') if ctrl => self.delete_to_end(),
KeyCode::Char('w') if ctrl => self.delete_word_before(),
KeyCode::Char(c) if !ctrl && !key.modifiers.contains(KeyModifiers::ALT) => {
self.insert(c);
}
KeyCode::Backspace => self.backspace(),
KeyCode::Delete => self.delete(),
KeyCode::Left => self.left(),
KeyCode::Right => self.right(),
KeyCode::Home => self.home(),
KeyCode::End => self.end(),
_ => return false,
}
true
}
pub fn visible(&self, width: usize) -> (String, usize) {
if width == 0 {
return (String::new(), 0);
}
let chars: Vec<char> = self.value.chars().collect();
if chars.len() < width {
return (self.value.clone(), self.cursor);
}
let start = (self.cursor + 1).saturating_sub(width);
let end = (start + width).min(chars.len());
let window: String = chars[start..end].iter().collect();
(window, self.cursor - start)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
#[test]
fn typing_inserts_at_the_cursor() {
let mut f = TextField::new();
for c in "abc".chars() {
f.insert(c);
}
f.left();
f.insert('X');
assert_eq!(f.value(), "abXc");
assert_eq!(f.cursor(), 3);
}
#[test]
fn with_value_puts_the_cursor_at_the_end() {
let f = TextField::with_value("hello");
assert_eq!(f.cursor(), 5);
}
#[test]
fn backspace_at_the_start_is_a_no_op() {
let mut f = TextField::with_value("ab");
f.home();
f.backspace();
assert_eq!(f.value(), "ab");
assert_eq!(f.cursor(), 0);
}
#[test]
fn delete_at_the_end_is_a_no_op() {
let mut f = TextField::with_value("ab");
f.delete();
assert_eq!(f.value(), "ab");
}
#[test]
fn multibyte_characters_never_split() {
let mut f = TextField::with_value("héllo wörld");
f.home();
f.right();
f.delete(); assert_eq!(f.value(), "hllo wörld");
let mut g = TextField::with_value("日本語");
g.backspace();
assert_eq!(g.value(), "日本");
assert_eq!(g.cursor(), 2);
}
#[test]
fn emoji_are_handled_as_single_units() {
let mut f = TextField::new();
f.insert('🦀');
f.insert('!');
assert_eq!(f.len_chars(), 2);
f.backspace();
f.backspace();
assert_eq!(f.value(), "");
}
#[test]
fn cursor_cannot_escape_the_value() {
let mut f = TextField::with_value("ab");
for _ in 0..10 {
f.right();
}
assert_eq!(f.cursor(), 2);
for _ in 0..10 {
f.left();
}
assert_eq!(f.cursor(), 0);
}
#[test]
fn ctrl_w_deletes_the_previous_word() {
let mut f = TextField::with_value("one two three");
f.handle_key(ctrl('w'));
assert_eq!(f.value(), "one two ");
f.handle_key(ctrl('w'));
assert_eq!(f.value(), "one ");
}
#[test]
fn ctrl_u_clears_to_the_start_and_ctrl_k_to_the_end() {
let mut f = TextField::with_value("hello world");
f.home();
for _ in 0..6 {
f.right();
}
f.handle_key(ctrl('k'));
assert_eq!(f.value(), "hello ");
f.handle_key(ctrl('u'));
assert_eq!(f.value(), "");
}
#[test]
fn ctrl_a_and_ctrl_e_jump_to_the_ends() {
let mut f = TextField::with_value("hello");
f.handle_key(ctrl('a'));
assert_eq!(f.cursor(), 0);
f.handle_key(ctrl('e'));
assert_eq!(f.cursor(), 5);
}
#[test]
fn control_chars_do_not_leak_into_the_value() {
let mut f = TextField::new();
f.handle_key(ctrl('a'));
assert_eq!(f.value(), "");
}
#[test]
fn unhandled_keys_are_reported_as_unused() {
let mut f = TextField::new();
assert!(!f.handle_key(key(KeyCode::Enter)));
assert!(!f.handle_key(key(KeyCode::Tab)));
assert!(!f.handle_key(key(KeyCode::Esc)));
assert!(f.handle_key(key(KeyCode::Char('x'))));
}
#[test]
fn visible_window_scrolls_to_follow_the_cursor() {
let f = TextField::with_value("abcdefghij");
let (text, col) = f.visible(5);
assert_eq!(text, "ghij");
assert_eq!(col, 4);
let mut mid = TextField::with_value("abcdefghij");
for _ in 0..5 {
mid.left();
}
let (text, col) = mid.visible(5);
assert_eq!(text.chars().count(), 5);
assert!(col < 5);
}
#[test]
fn visible_never_exceeds_the_requested_width() {
for len in 0..20usize {
let value: String = "abcdefghijklmnopqrst".chars().take(len).collect();
let mut field = TextField::with_value(value);
for cursor_moves in 0..=len {
for _ in 0..cursor_moves {
field.left();
}
for width in 1..8usize {
let (text, col) = field.visible(width);
assert!(
text.chars().count() <= width,
"len={len} width={width} produced {} chars",
text.chars().count()
);
assert!(col <= width, "cursor column {col} escaped width {width}");
}
field.end();
}
}
}
#[test]
fn visible_returns_the_whole_value_when_it_fits() {
let short = TextField::with_value("ab");
assert_eq!(short.visible(10), ("ab".to_string(), 2));
}
#[test]
fn visible_handles_zero_width() {
let f = TextField::with_value("abc");
assert_eq!(f.visible(0), (String::new(), 0));
}
}