Skip to main content

tui/components/
text_field.rs

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