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