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