Skip to main content

tui/components/
text_field.rs

1use crossterm::event::{KeyCode, KeyModifiers};
2use unicode_width::UnicodeWidthChar;
3
4use crate::components::{Component, Event, ViewContext};
5use crate::line::Line;
6use crate::rendering::frame::Frame;
7use crate::rendering::soft_wrap::display_width_text;
8
9/// Single-line text input with cursor tracking and navigation.
10pub struct TextField {
11    pub value: String,
12    cursor_pos: usize,
13    content_width: usize,
14}
15
16impl TextField {
17    pub fn new(value: String) -> Self {
18        let cursor_pos = value.len();
19        Self {
20            value,
21            cursor_pos,
22            content_width: usize::MAX,
23        }
24    }
25
26    pub fn set_content_width(&mut self, width: usize) {
27        self.content_width = width.max(1);
28    }
29
30    pub fn cursor_pos(&self) -> usize {
31        self.cursor_pos
32    }
33
34    pub fn set_cursor_pos(&mut self, pos: usize) {
35        self.cursor_pos = pos.min(self.value.len());
36    }
37
38    pub fn insert_at_cursor(&mut self, c: char) {
39        self.value.insert(self.cursor_pos, c);
40        self.cursor_pos += c.len_utf8();
41    }
42
43    pub fn insert_str_at_cursor(&mut self, s: &str) {
44        self.value.insert_str(self.cursor_pos, s);
45        self.cursor_pos += s.len();
46    }
47
48    pub fn delete_before_cursor(&mut self) -> bool {
49        let Some((prev, _)) = self.value[..self.cursor_pos].char_indices().next_back() else {
50            return false;
51        };
52        self.value.drain(prev..self.cursor_pos);
53        self.cursor_pos = prev;
54        true
55    }
56
57    pub fn set_value(&mut self, value: String) {
58        self.cursor_pos = value.len();
59        self.value = value;
60    }
61
62    pub fn clear(&mut self) {
63        self.value.clear();
64        self.cursor_pos = 0;
65    }
66
67    pub fn to_json(&self) -> serde_json::Value {
68        serde_json::Value::String(self.value.clone())
69    }
70
71    pub fn render_field(&self, context: &ViewContext, focused: bool) -> Vec<Line> {
72        let mut line = Line::new(&self.value);
73        if focused {
74            line.push_styled("▏", context.theme.primary());
75        }
76        vec![line]
77    }
78
79    fn delete_after_cursor(&mut self) {
80        if let Some(c) = self.value[self.cursor_pos..].chars().next() {
81            self.value
82                .drain(self.cursor_pos..self.cursor_pos + c.len_utf8());
83        }
84    }
85
86    fn delete_word_backward(&mut self) {
87        let end = self.cursor_pos;
88        let start = self.word_start_backward();
89        self.cursor_pos = start;
90        self.value.drain(start..end);
91    }
92
93    fn word_end_forward(&mut self) {
94        let len = self.value.len();
95        while self.cursor_pos < len {
96            let ch = self.value[self.cursor_pos..].chars().next().unwrap();
97            if ch.is_whitespace() {
98                break;
99            }
100            self.cursor_pos += ch.len_utf8();
101        }
102        while self.cursor_pos < len {
103            let ch = self.value[self.cursor_pos..].chars().next().unwrap();
104            if !ch.is_whitespace() {
105                break;
106            }
107            self.cursor_pos += ch.len_utf8();
108        }
109    }
110
111    fn move_cursor_up(&mut self, content_width: usize) {
112        if content_width == 0 {
113            return;
114        }
115        let cursor_width = self.display_width_up_to(self.cursor_pos);
116        let row = cursor_width / content_width;
117        if row == 0 {
118            self.cursor_pos = 0;
119        } else {
120            let col = cursor_width % content_width;
121            let target = (row - 1) * content_width + col;
122            self.cursor_pos = self.byte_offset_for_display_width(target);
123        }
124    }
125
126    fn move_cursor_down(&mut self, content_width: usize) {
127        if content_width == 0 {
128            return;
129        }
130        let cursor_width = self.display_width_up_to(self.cursor_pos);
131        let total_width = self.display_width_up_to(self.value.len());
132        let row = cursor_width / content_width;
133        let max_row = total_width / content_width;
134        if row >= max_row {
135            self.cursor_pos = self.value.len();
136        } else {
137            let col = cursor_width % content_width;
138            let target = ((row + 1) * content_width + col).min(total_width);
139            self.cursor_pos = self.byte_offset_for_display_width(target);
140        }
141    }
142
143    fn word_start_backward(&self) -> usize {
144        let mut pos = self.cursor_pos;
145        while pos > 0 {
146            let (i, ch) = self.value[..pos].char_indices().next_back().unwrap();
147            if !ch.is_whitespace() {
148                break;
149            }
150            pos = i;
151        }
152        while pos > 0 {
153            let (i, ch) = self.value[..pos].char_indices().next_back().unwrap();
154            if ch.is_whitespace() {
155                break;
156            }
157            pos = i;
158        }
159        pos
160    }
161
162    fn display_width_up_to(&self, byte_pos: usize) -> usize {
163        display_width_text(&self.value[..byte_pos])
164    }
165
166    fn byte_offset_for_display_width(&self, target_width: usize) -> usize {
167        let mut width = 0;
168        for (i, ch) in self.value.char_indices() {
169            if width >= target_width {
170                return i;
171            }
172            width += UnicodeWidthChar::width(ch).unwrap_or(0);
173        }
174        self.value.len()
175    }
176}
177
178impl Component for TextField {
179    type Message = ();
180
181    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
182        match event {
183            Event::Key(key) => {
184                let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
185                let alt = key.modifiers.contains(KeyModifiers::ALT);
186                match key.code {
187                    KeyCode::Char('a') if ctrl => {
188                        self.cursor_pos = 0;
189                        Some(vec![])
190                    }
191                    KeyCode::Char('e') if ctrl => {
192                        self.cursor_pos = self.value.len();
193                        Some(vec![])
194                    }
195                    KeyCode::Char('w') if ctrl => {
196                        self.delete_word_backward();
197                        Some(vec![])
198                    }
199                    KeyCode::Char('u') if ctrl => {
200                        self.value.drain(..self.cursor_pos);
201                        self.cursor_pos = 0;
202                        Some(vec![])
203                    }
204                    KeyCode::Char('k') if ctrl => {
205                        self.value.truncate(self.cursor_pos);
206                        Some(vec![])
207                    }
208                    KeyCode::Backspace if alt => {
209                        self.delete_word_backward();
210                        Some(vec![])
211                    }
212                    KeyCode::Left if alt || ctrl => {
213                        self.cursor_pos = self.word_start_backward();
214                        Some(vec![])
215                    }
216                    KeyCode::Right if alt || ctrl => {
217                        self.word_end_forward();
218                        Some(vec![])
219                    }
220                    KeyCode::Delete => {
221                        self.delete_after_cursor();
222                        Some(vec![])
223                    }
224                    KeyCode::Char(c) if !ctrl => {
225                        self.insert_at_cursor(c);
226                        Some(vec![])
227                    }
228                    KeyCode::Backspace => {
229                        self.delete_before_cursor();
230                        Some(vec![])
231                    }
232                    KeyCode::Left => {
233                        self.cursor_pos = self.value[..self.cursor_pos]
234                            .char_indices()
235                            .next_back()
236                            .map_or(0, |(i, _)| i);
237                        Some(vec![])
238                    }
239                    KeyCode::Right => {
240                        if let Some(c) = self.value[self.cursor_pos..].chars().next() {
241                            self.cursor_pos += c.len_utf8();
242                        }
243                        Some(vec![])
244                    }
245                    KeyCode::Home => {
246                        self.cursor_pos = 0;
247                        Some(vec![])
248                    }
249                    KeyCode::End => {
250                        self.cursor_pos = self.value.len();
251                        Some(vec![])
252                    }
253                    KeyCode::Up => {
254                        self.move_cursor_up(self.content_width);
255                        Some(vec![])
256                    }
257                    KeyCode::Down => {
258                        self.move_cursor_down(self.content_width);
259                        Some(vec![])
260                    }
261                    _ => None,
262                }
263            }
264            Event::Paste(text) => {
265                self.insert_str_at_cursor(text);
266                Some(vec![])
267            }
268            _ => None,
269        }
270    }
271
272    fn render(&mut self, context: &ViewContext) -> Frame {
273        Frame::new(self.render_field(context, true))
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crossterm::event::{KeyEvent, KeyModifiers};
281
282    fn key(code: KeyCode) -> KeyEvent {
283        KeyEvent::new(code, KeyModifiers::NONE)
284    }
285    fn ctrl(code: KeyCode) -> KeyEvent {
286        KeyEvent::new(code, KeyModifiers::CONTROL)
287    }
288    fn alt(code: KeyCode) -> KeyEvent {
289        KeyEvent::new(code, KeyModifiers::ALT)
290    }
291    fn field(text: &str) -> TextField {
292        TextField::new(text.to_string())
293    }
294    fn field_at(text: &str, cursor: usize) -> TextField {
295        let mut f = field(text);
296        f.set_cursor_pos(cursor);
297        f
298    }
299
300    async fn send(f: &mut TextField, evt: Event) -> Option<Vec<()>> {
301        f.on_event(&evt).await
302    }
303    async fn send_key(f: &mut TextField, k: KeyEvent) -> Option<Vec<()>> {
304        send(f, Event::Key(k)).await
305    }
306
307    /// Assert both value and cursor position.
308    fn assert_state(f: &TextField, value: &str, cursor: usize) {
309        assert_eq!(f.value, value, "value mismatch");
310        assert_eq!(f.cursor_pos(), cursor, "cursor mismatch");
311    }
312
313    #[tokio::test]
314    async fn typing_appends_characters() {
315        let mut f = field("");
316        send_key(&mut f, key(KeyCode::Char('h'))).await;
317        send_key(&mut f, key(KeyCode::Char('i'))).await;
318        assert_eq!(f.value, "hi");
319    }
320
321    #[tokio::test]
322    async fn backspace_variants() {
323        // End of string
324        let mut f = field("abc");
325        send_key(&mut f, key(KeyCode::Backspace)).await;
326        assert_eq!(f.value, "ab");
327
328        // Empty string (no-op)
329        let mut f = field("");
330        send_key(&mut f, key(KeyCode::Backspace)).await;
331        assert_eq!(f.value, "");
332
333        // Middle of string
334        let mut f = field_at("hello", 3);
335        send_key(&mut f, key(KeyCode::Backspace)).await;
336        assert_state(&f, "helo", 2);
337    }
338
339    #[test]
340    fn to_json_returns_string_value() {
341        assert_eq!(field("hello").to_json(), serde_json::json!("hello"));
342    }
343
344    #[tokio::test]
345    async fn unhandled_keys_are_ignored() {
346        let mut f = field("");
347        assert!(send_key(&mut f, key(KeyCode::F(1))).await.is_none());
348    }
349
350    #[tokio::test]
351    async fn paste_variants() {
352        // Into empty field
353        let mut f = field("");
354        let outcome = send(&mut f, Event::Paste("hello".into())).await;
355        assert!(outcome.is_some());
356        assert_eq!(f.value, "hello");
357
358        // At cursor position
359        let mut f = field_at("hd", 1);
360        send(&mut f, Event::Paste("ello worl".into())).await;
361        assert_state(&f, "hello world", 10);
362    }
363
364    #[test]
365    fn cursor_starts_at_end() {
366        assert_eq!(field("hello").cursor_pos(), 5);
367    }
368
369    #[tokio::test]
370    async fn cursor_movement_single_keys() {
371        // (initial_text, initial_cursor, key_event, expected_cursor)
372        let cases: Vec<(&str, Option<usize>, KeyEvent, usize)> = vec![
373            ("hello", None, key(KeyCode::Left), 4),
374            ("hello", None, key(KeyCode::Right), 5),
375            ("", None, key(KeyCode::Left), 0),
376            ("hello", None, key(KeyCode::Home), 0),
377            ("hello", Some(0), key(KeyCode::End), 5),
378            ("hello", None, ctrl(KeyCode::Char('a')), 0),
379            ("hello", Some(0), ctrl(KeyCode::Char('e')), 5),
380        ];
381        for (text, cursor, k, expected) in cases {
382            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
383            send_key(&mut f, k).await;
384            assert_eq!(f.cursor_pos(), expected, "failed for key {k:?} on {text:?}");
385        }
386    }
387
388    #[tokio::test]
389    async fn insert_at_middle() {
390        let mut f = field_at("hllo", 1);
391        send_key(&mut f, key(KeyCode::Char('e'))).await;
392        assert_state(&f, "hello", 2);
393    }
394
395    #[tokio::test]
396    async fn multibyte_utf8_navigation() {
397        let mut f = field("a中b");
398        assert_eq!(f.cursor_pos(), 5);
399        for expected in [4, 1, 0] {
400            send_key(&mut f, key(KeyCode::Left)).await;
401            assert_eq!(f.cursor_pos(), expected);
402        }
403        for expected in [1, 4] {
404            send_key(&mut f, key(KeyCode::Right)).await;
405            assert_eq!(f.cursor_pos(), expected);
406        }
407    }
408
409    #[test]
410    fn set_value_moves_cursor_to_end() {
411        let mut f = field("");
412        f.set_value("hello".to_string());
413        assert_state(&f, "hello", 5);
414    }
415
416    #[test]
417    fn clear_resets_cursor() {
418        let mut f = field("hello");
419        f.clear();
420        assert_state(&f, "", 0);
421    }
422
423    #[tokio::test]
424    async fn delete_forward_variants() {
425        // Middle of string
426        let mut f = field_at("hello", 2);
427        send_key(&mut f, key(KeyCode::Delete)).await;
428        assert_state(&f, "helo", 2);
429
430        // At end (no-op)
431        let mut f = field("hello");
432        send_key(&mut f, key(KeyCode::Delete)).await;
433        assert_eq!(f.value, "hello");
434
435        // Multibyte character
436        let mut f = field_at("a中b", 1);
437        send_key(&mut f, key(KeyCode::Delete)).await;
438        assert_state(&f, "ab", 1);
439    }
440
441    #[tokio::test]
442    async fn ctrl_w_variants() {
443        // (text, cursor, expected_value, expected_cursor)
444        let cases: Vec<(&str, Option<usize>, &str, usize)> = vec![
445            ("hello world", None, "hello ", 6),
446            ("hello   ", None, "", 0),
447            ("hello", Some(0), "hello", 0),
448            ("hello world", Some(8), "hello rld", 6),
449            ("", None, "", 0),
450        ];
451        for (text, cursor, exp_val, exp_cur) in cases {
452            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
453            send_key(&mut f, ctrl(KeyCode::Char('w'))).await;
454            assert_state(&f, exp_val, exp_cur);
455        }
456    }
457
458    #[tokio::test]
459    async fn alt_backspace_deletes_word() {
460        let mut f = field("hello world");
461        send_key(&mut f, alt(KeyCode::Backspace)).await;
462        assert_eq!(f.value, "hello ");
463    }
464
465    #[tokio::test]
466    async fn ctrl_u_variants() {
467        let mut f = field_at("hello world", 5);
468        send_key(&mut f, ctrl(KeyCode::Char('u'))).await;
469        assert_state(&f, " world", 0);
470
471        // At start (no-op)
472        let mut f = field_at("hello", 0);
473        send_key(&mut f, ctrl(KeyCode::Char('u'))).await;
474        assert_state(&f, "hello", 0);
475    }
476
477    #[tokio::test]
478    async fn ctrl_k_variants() {
479        let mut f = field_at("hello world", 5);
480        send_key(&mut f, ctrl(KeyCode::Char('k'))).await;
481        assert_state(&f, "hello", 5);
482
483        // At end (no-op)
484        let mut f = field("hello");
485        send_key(&mut f, ctrl(KeyCode::Char('k'))).await;
486        assert_eq!(f.value, "hello");
487    }
488
489    #[tokio::test]
490    async fn word_navigation() {
491        // (text, cursor, key_event, expected_cursor)
492        let cases: Vec<(&str, Option<usize>, KeyEvent, usize)> = vec![
493            ("hello world", None, alt(KeyCode::Left), 6),
494            ("hello world", Some(8), alt(KeyCode::Left), 6),
495            ("hello", Some(0), alt(KeyCode::Left), 0),
496            ("hello world", None, ctrl(KeyCode::Left), 6),
497            ("hello world", Some(0), alt(KeyCode::Right), 6),
498            ("hello", None, alt(KeyCode::Right), 5),
499            ("a中 b", Some(0), alt(KeyCode::Right), 5),
500            ("hello world", Some(0), ctrl(KeyCode::Right), 6),
501        ];
502        for (text, cursor, k, expected) in cases {
503            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
504            send_key(&mut f, k).await;
505            assert_eq!(
506                f.cursor_pos(),
507                expected,
508                "failed for {k:?} on {text:?} at {cursor:?}"
509            );
510        }
511    }
512
513    #[test]
514    fn move_cursor_up_cases() {
515        // (text, cursor, width, expected)
516        let cases: Vec<(&str, Option<usize>, usize, usize)> = vec![
517            ("hello world", Some(3), 10, 0), // first row goes home
518            ("hello world", Some(8), 5, 3),  // multi-row: row1->row0
519            ("hello", Some(3), 0, 3),        // zero width is no-op
520        ];
521        for (text, cursor, width, expected) in cases {
522            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
523            f.move_cursor_up(width);
524            assert_eq!(
525                f.cursor_pos(),
526                expected,
527                "up failed: {text:?} cursor={cursor:?} w={width}"
528            );
529        }
530    }
531
532    #[test]
533    fn move_cursor_up_wide_chars() {
534        // '中' is 2 display-width columns
535        // "中中中中中" = 10 display cols, content_width=5 -> 2 rows
536        // Cursor at end: byte 15, display 10, row 2, col 0
537        // byte_offset_for_display_width(5): wide chars can't land exactly on boundary
538        let mut f = field("中中中中中");
539        f.move_cursor_up(5);
540        assert_eq!(f.cursor_pos(), 9);
541    }
542
543    #[test]
544    fn move_cursor_down_cases() {
545        // (text, cursor, width, expected)
546        let cases: Vec<(&str, Option<usize>, usize, usize)> = vec![
547            ("hello world", Some(0), 20, 11), // last row goes end
548            ("hello world", Some(3), 5, 8),   // multi-row: row0->row1
549            ("hello world", Some(8), 5, 11),  // clamps to total width
550            ("", None, 10, 0),                // empty string
551        ];
552        for (text, cursor, width, expected) in cases {
553            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
554            f.move_cursor_down(width);
555            assert_eq!(
556                f.cursor_pos(),
557                expected,
558                "down failed: {text:?} cursor={cursor:?} w={width}"
559            );
560        }
561    }
562}