use crossterm::event::{KeyCode, KeyModifiers};
use unicode_width::UnicodeWidthChar;
use crate::components::{Component, Event, ViewContext};
use crate::line::Line;
use crate::rendering::frame::Frame;
use crate::rendering::soft_wrap::display_width_text;
pub struct TextField {
pub value: String,
cursor_pos: usize,
content_width: usize,
}
impl TextField {
pub fn new(value: String) -> Self {
let cursor_pos = value.len();
Self { value, cursor_pos, content_width: usize::MAX }
}
pub fn set_content_width(&mut self, width: usize) {
self.content_width = width.max(1);
}
pub fn cursor_pos(&self) -> usize {
self.cursor_pos
}
pub fn set_cursor_pos(&mut self, pos: usize) {
self.cursor_pos = pos.min(self.value.len());
}
pub fn insert_at_cursor(&mut self, c: char) {
self.value.insert(self.cursor_pos, c);
self.cursor_pos += c.len_utf8();
}
pub fn insert_str_at_cursor(&mut self, s: &str) {
self.value.insert_str(self.cursor_pos, s);
self.cursor_pos += s.len();
}
pub fn delete_before_cursor(&mut self) -> bool {
let Some((prev, _)) = self.value[..self.cursor_pos].char_indices().next_back() else {
return false;
};
self.value.drain(prev..self.cursor_pos);
self.cursor_pos = prev;
true
}
pub fn set_value(&mut self, value: String) {
self.cursor_pos = value.len();
self.value = value;
}
pub fn clear(&mut self) {
self.value.clear();
self.cursor_pos = 0;
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::Value::String(self.value.clone())
}
pub fn render_field(&self, context: &ViewContext, focused: bool) -> Vec<Line> {
let mut line = Line::new(&self.value);
if focused {
line.push_styled("▏", context.theme.primary());
}
vec![line]
}
fn delete_after_cursor(&mut self) {
if let Some(c) = self.value[self.cursor_pos..].chars().next() {
self.value.drain(self.cursor_pos..self.cursor_pos + c.len_utf8());
}
}
fn delete_word_backward(&mut self) {
let end = self.cursor_pos;
let start = self.word_start_backward();
self.cursor_pos = start;
self.value.drain(start..end);
}
fn word_end_forward(&mut self) {
let len = self.value.len();
while self.cursor_pos < len {
let ch = self.value[self.cursor_pos..].chars().next().unwrap();
if ch.is_whitespace() {
break;
}
self.cursor_pos += ch.len_utf8();
}
while self.cursor_pos < len {
let ch = self.value[self.cursor_pos..].chars().next().unwrap();
if !ch.is_whitespace() {
break;
}
self.cursor_pos += ch.len_utf8();
}
}
fn move_cursor_up(&mut self, content_width: usize) {
if content_width == 0 {
return;
}
let cursor_width = self.display_width_up_to(self.cursor_pos);
let row = cursor_width / content_width;
if row == 0 {
self.cursor_pos = 0;
} else {
let col = cursor_width % content_width;
let target = (row - 1) * content_width + col;
self.cursor_pos = self.byte_offset_for_display_width(target);
}
}
fn move_cursor_down(&mut self, content_width: usize) {
if content_width == 0 {
return;
}
let cursor_width = self.display_width_up_to(self.cursor_pos);
let total_width = self.display_width_up_to(self.value.len());
let row = cursor_width / content_width;
let max_row = total_width / content_width;
if row >= max_row {
self.cursor_pos = self.value.len();
} else {
let col = cursor_width % content_width;
let target = ((row + 1) * content_width + col).min(total_width);
self.cursor_pos = self.byte_offset_for_display_width(target);
}
}
fn word_start_backward(&self) -> usize {
let mut pos = self.cursor_pos;
while pos > 0 {
let (i, ch) = self.value[..pos].char_indices().next_back().unwrap();
if !ch.is_whitespace() {
break;
}
pos = i;
}
while pos > 0 {
let (i, ch) = self.value[..pos].char_indices().next_back().unwrap();
if ch.is_whitespace() {
break;
}
pos = i;
}
pos
}
fn display_width_up_to(&self, byte_pos: usize) -> usize {
display_width_text(&self.value[..byte_pos])
}
fn byte_offset_for_display_width(&self, target_width: usize) -> usize {
let mut width = 0;
for (i, ch) in self.value.char_indices() {
if width >= target_width {
return i;
}
width += UnicodeWidthChar::width(ch).unwrap_or(0);
}
self.value.len()
}
}
impl Component for TextField {
type Message = ();
async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
match event {
Event::Key(key) => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Char('a') if ctrl => {
self.cursor_pos = 0;
Some(vec![])
}
KeyCode::Char('e') if ctrl => {
self.cursor_pos = self.value.len();
Some(vec![])
}
KeyCode::Char('w') if ctrl => {
self.delete_word_backward();
Some(vec![])
}
KeyCode::Char('u') if ctrl => {
self.value.drain(..self.cursor_pos);
self.cursor_pos = 0;
Some(vec![])
}
KeyCode::Char('k') if ctrl => {
self.value.truncate(self.cursor_pos);
Some(vec![])
}
KeyCode::Backspace if alt => {
self.delete_word_backward();
Some(vec![])
}
KeyCode::Left if alt || ctrl => {
self.cursor_pos = self.word_start_backward();
Some(vec![])
}
KeyCode::Right if alt || ctrl => {
self.word_end_forward();
Some(vec![])
}
KeyCode::Delete => {
self.delete_after_cursor();
Some(vec![])
}
KeyCode::Char(c) if !ctrl => {
self.insert_at_cursor(c);
Some(vec![])
}
KeyCode::Backspace => {
self.delete_before_cursor();
Some(vec![])
}
KeyCode::Left => {
self.cursor_pos =
self.value[..self.cursor_pos].char_indices().next_back().map_or(0, |(i, _)| i);
Some(vec![])
}
KeyCode::Right => {
if let Some(c) = self.value[self.cursor_pos..].chars().next() {
self.cursor_pos += c.len_utf8();
}
Some(vec![])
}
KeyCode::Home => {
self.cursor_pos = 0;
Some(vec![])
}
KeyCode::End => {
self.cursor_pos = self.value.len();
Some(vec![])
}
KeyCode::Up => {
self.move_cursor_up(self.content_width);
Some(vec![])
}
KeyCode::Down => {
self.move_cursor_down(self.content_width);
Some(vec![])
}
_ => None,
}
}
Event::Paste(text) => {
self.insert_str_at_cursor(text);
Some(vec![])
}
_ => None,
}
}
fn render(&mut self, context: &ViewContext) -> Frame {
Frame::new(self.render_field(context, true))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
fn alt(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::ALT)
}
fn field(text: &str) -> TextField {
TextField::new(text.to_string())
}
fn field_at(text: &str, cursor: usize) -> TextField {
let mut f = field(text);
f.set_cursor_pos(cursor);
f
}
async fn send(f: &mut TextField, evt: Event) -> Option<Vec<()>> {
f.on_event(&evt).await
}
async fn send_key(f: &mut TextField, k: KeyEvent) -> Option<Vec<()>> {
send(f, Event::Key(k)).await
}
fn assert_state(f: &TextField, value: &str, cursor: usize) {
assert_eq!(f.value, value, "value mismatch");
assert_eq!(f.cursor_pos(), cursor, "cursor mismatch");
}
#[tokio::test]
async fn typing_appends_characters() {
let mut f = field("");
send_key(&mut f, key(KeyCode::Char('h'))).await;
send_key(&mut f, key(KeyCode::Char('i'))).await;
assert_eq!(f.value, "hi");
}
#[tokio::test]
async fn backspace_variants() {
let mut f = field("abc");
send_key(&mut f, key(KeyCode::Backspace)).await;
assert_eq!(f.value, "ab");
let mut f = field("");
send_key(&mut f, key(KeyCode::Backspace)).await;
assert_eq!(f.value, "");
let mut f = field_at("hello", 3);
send_key(&mut f, key(KeyCode::Backspace)).await;
assert_state(&f, "helo", 2);
}
#[test]
fn to_json_returns_string_value() {
assert_eq!(field("hello").to_json(), serde_json::json!("hello"));
}
#[tokio::test]
async fn unhandled_keys_are_ignored() {
let mut f = field("");
assert!(send_key(&mut f, key(KeyCode::F(1))).await.is_none());
}
#[tokio::test]
async fn paste_variants() {
let mut f = field("");
let outcome = send(&mut f, Event::Paste("hello".into())).await;
assert!(outcome.is_some());
assert_eq!(f.value, "hello");
let mut f = field_at("hd", 1);
send(&mut f, Event::Paste("ello worl".into())).await;
assert_state(&f, "hello world", 10);
}
#[test]
fn cursor_starts_at_end() {
assert_eq!(field("hello").cursor_pos(), 5);
}
#[tokio::test]
async fn cursor_movement_single_keys() {
let cases: Vec<(&str, Option<usize>, KeyEvent, usize)> = vec![
("hello", None, key(KeyCode::Left), 4),
("hello", None, key(KeyCode::Right), 5),
("", None, key(KeyCode::Left), 0),
("hello", None, key(KeyCode::Home), 0),
("hello", Some(0), key(KeyCode::End), 5),
("hello", None, ctrl(KeyCode::Char('a')), 0),
("hello", Some(0), ctrl(KeyCode::Char('e')), 5),
];
for (text, cursor, k, expected) in cases {
let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
send_key(&mut f, k).await;
assert_eq!(f.cursor_pos(), expected, "failed for key {k:?} on {text:?}");
}
}
#[tokio::test]
async fn insert_at_middle() {
let mut f = field_at("hllo", 1);
send_key(&mut f, key(KeyCode::Char('e'))).await;
assert_state(&f, "hello", 2);
}
#[tokio::test]
async fn multibyte_utf8_navigation() {
let mut f = field("a中b");
assert_eq!(f.cursor_pos(), 5);
for expected in [4, 1, 0] {
send_key(&mut f, key(KeyCode::Left)).await;
assert_eq!(f.cursor_pos(), expected);
}
for expected in [1, 4] {
send_key(&mut f, key(KeyCode::Right)).await;
assert_eq!(f.cursor_pos(), expected);
}
}
#[test]
fn set_value_moves_cursor_to_end() {
let mut f = field("");
f.set_value("hello".to_string());
assert_state(&f, "hello", 5);
}
#[test]
fn clear_resets_cursor() {
let mut f = field("hello");
f.clear();
assert_state(&f, "", 0);
}
#[tokio::test]
async fn delete_forward_variants() {
let mut f = field_at("hello", 2);
send_key(&mut f, key(KeyCode::Delete)).await;
assert_state(&f, "helo", 2);
let mut f = field("hello");
send_key(&mut f, key(KeyCode::Delete)).await;
assert_eq!(f.value, "hello");
let mut f = field_at("a中b", 1);
send_key(&mut f, key(KeyCode::Delete)).await;
assert_state(&f, "ab", 1);
}
#[tokio::test]
async fn ctrl_w_variants() {
let cases: Vec<(&str, Option<usize>, &str, usize)> = vec![
("hello world", None, "hello ", 6),
("hello ", None, "", 0),
("hello", Some(0), "hello", 0),
("hello world", Some(8), "hello rld", 6),
("", None, "", 0),
];
for (text, cursor, exp_val, exp_cur) in cases {
let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
send_key(&mut f, ctrl(KeyCode::Char('w'))).await;
assert_state(&f, exp_val, exp_cur);
}
}
#[tokio::test]
async fn alt_backspace_deletes_word() {
let mut f = field("hello world");
send_key(&mut f, alt(KeyCode::Backspace)).await;
assert_eq!(f.value, "hello ");
}
#[tokio::test]
async fn ctrl_u_variants() {
let mut f = field_at("hello world", 5);
send_key(&mut f, ctrl(KeyCode::Char('u'))).await;
assert_state(&f, " world", 0);
let mut f = field_at("hello", 0);
send_key(&mut f, ctrl(KeyCode::Char('u'))).await;
assert_state(&f, "hello", 0);
}
#[tokio::test]
async fn ctrl_k_variants() {
let mut f = field_at("hello world", 5);
send_key(&mut f, ctrl(KeyCode::Char('k'))).await;
assert_state(&f, "hello", 5);
let mut f = field("hello");
send_key(&mut f, ctrl(KeyCode::Char('k'))).await;
assert_eq!(f.value, "hello");
}
#[tokio::test]
async fn word_navigation() {
let cases: Vec<(&str, Option<usize>, KeyEvent, usize)> = vec![
("hello world", None, alt(KeyCode::Left), 6),
("hello world", Some(8), alt(KeyCode::Left), 6),
("hello", Some(0), alt(KeyCode::Left), 0),
("hello world", None, ctrl(KeyCode::Left), 6),
("hello world", Some(0), alt(KeyCode::Right), 6),
("hello", None, alt(KeyCode::Right), 5),
("a中 b", Some(0), alt(KeyCode::Right), 5),
("hello world", Some(0), ctrl(KeyCode::Right), 6),
];
for (text, cursor, k, expected) in cases {
let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
send_key(&mut f, k).await;
assert_eq!(f.cursor_pos(), expected, "failed for {k:?} on {text:?} at {cursor:?}");
}
}
#[test]
fn move_cursor_up_cases() {
let cases: Vec<(&str, Option<usize>, usize, usize)> = vec![
("hello world", Some(3), 10, 0), ("hello world", Some(8), 5, 3), ("hello", Some(3), 0, 3), ];
for (text, cursor, width, expected) in cases {
let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
f.move_cursor_up(width);
assert_eq!(f.cursor_pos(), expected, "up failed: {text:?} cursor={cursor:?} w={width}");
}
}
#[test]
fn move_cursor_up_wide_chars() {
let mut f = field("中中中中中");
f.move_cursor_up(5);
assert_eq!(f.cursor_pos(), 9);
}
#[test]
fn move_cursor_down_cases() {
let cases: Vec<(&str, Option<usize>, usize, usize)> = vec![
("hello world", Some(0), 20, 11), ("hello world", Some(3), 5, 8), ("hello world", Some(8), 5, 11), ("", None, 10, 0), ];
for (text, cursor, width, expected) in cases {
let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
f.move_cursor_down(width);
assert_eq!(f.cursor_pos(), expected, "down failed: {text:?} cursor={cursor:?} w={width}");
}
}
}