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::Right if alt || ctrl => {
234                        self.word_end_forward();
235                        Some(vec![])
236                    }
237                    KeyCode::Delete => {
238                        self.delete_after_cursor();
239                        Some(vec![])
240                    }
241                    KeyCode::Char(c) if !ctrl => {
242                        self.insert_at_cursor(c);
243                        Some(vec![])
244                    }
245                    KeyCode::Backspace => {
246                        self.delete_before_cursor();
247                        Some(vec![])
248                    }
249                    KeyCode::Left => {
250                        self.cursor_pos =
251                            self.value[..self.cursor_pos].char_indices().next_back().map_or(0, |(i, _)| i);
252                        Some(vec![])
253                    }
254                    KeyCode::Right => {
255                        if let Some(c) = self.value[self.cursor_pos..].chars().next() {
256                            self.cursor_pos += c.len_utf8();
257                        }
258                        Some(vec![])
259                    }
260                    KeyCode::Home => {
261                        self.cursor_pos = 0;
262                        Some(vec![])
263                    }
264                    KeyCode::End => {
265                        self.cursor_pos = self.value.len();
266                        Some(vec![])
267                    }
268                    KeyCode::Up => {
269                        self.move_cursor_up(self.content_width);
270                        Some(vec![])
271                    }
272                    KeyCode::Down => {
273                        self.move_cursor_down(self.content_width);
274                        Some(vec![])
275                    }
276                    _ => None,
277                }
278            }
279            Event::Paste(text) => {
280                self.insert_str_at_cursor(text);
281                Some(vec![])
282            }
283            _ => None,
284        }
285    }
286
287    fn render(&mut self, context: &ViewContext) -> Frame {
288        Frame::new(self.render_field(context, true))
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crossterm::event::{KeyEvent, KeyModifiers};
296
297    fn key(code: KeyCode) -> KeyEvent {
298        KeyEvent::new(code, KeyModifiers::NONE)
299    }
300    fn ctrl(code: KeyCode) -> KeyEvent {
301        KeyEvent::new(code, KeyModifiers::CONTROL)
302    }
303    fn alt(code: KeyCode) -> KeyEvent {
304        KeyEvent::new(code, KeyModifiers::ALT)
305    }
306    fn field(text: &str) -> TextField {
307        TextField::new(text.to_string())
308    }
309    fn field_at(text: &str, cursor: usize) -> TextField {
310        let mut f = field(text);
311        f.set_cursor_pos(cursor);
312        f
313    }
314
315    async fn send(f: &mut TextField, evt: Event) -> Option<Vec<()>> {
316        f.on_event(&evt).await
317    }
318    async fn send_key(f: &mut TextField, k: KeyEvent) -> Option<Vec<()>> {
319        send(f, Event::Key(k)).await
320    }
321
322    /// Assert both value and cursor position.
323    fn assert_state(f: &TextField, value: &str, cursor: usize) {
324        assert_eq!(f.value, value, "value mismatch");
325        assert_eq!(f.cursor_pos(), cursor, "cursor mismatch");
326    }
327
328    #[tokio::test]
329    async fn typing_appends_characters() {
330        let mut f = field("");
331        send_key(&mut f, key(KeyCode::Char('h'))).await;
332        send_key(&mut f, key(KeyCode::Char('i'))).await;
333        assert_eq!(f.value, "hi");
334    }
335
336    #[tokio::test]
337    async fn backspace_variants() {
338        // End of string
339        let mut f = field("abc");
340        send_key(&mut f, key(KeyCode::Backspace)).await;
341        assert_eq!(f.value, "ab");
342
343        // Empty string (no-op)
344        let mut f = field("");
345        send_key(&mut f, key(KeyCode::Backspace)).await;
346        assert_eq!(f.value, "");
347
348        // Middle of string
349        let mut f = field_at("hello", 3);
350        send_key(&mut f, key(KeyCode::Backspace)).await;
351        assert_state(&f, "helo", 2);
352    }
353
354    #[test]
355    fn to_json_returns_string_value() {
356        assert_eq!(field("hello").to_json(), serde_json::json!("hello"));
357    }
358
359    #[tokio::test]
360    async fn unhandled_keys_are_ignored() {
361        let mut f = field("");
362        assert!(send_key(&mut f, key(KeyCode::F(1))).await.is_none());
363    }
364
365    #[tokio::test]
366    async fn paste_variants() {
367        // Into empty field
368        let mut f = field("");
369        let outcome = send(&mut f, Event::Paste("hello".into())).await;
370        assert!(outcome.is_some());
371        assert_eq!(f.value, "hello");
372
373        // At cursor position
374        let mut f = field_at("hd", 1);
375        send(&mut f, Event::Paste("ello worl".into())).await;
376        assert_state(&f, "hello world", 10);
377    }
378
379    #[test]
380    fn cursor_starts_at_end() {
381        assert_eq!(field("hello").cursor_pos(), 5);
382    }
383
384    #[tokio::test]
385    async fn cursor_movement_single_keys() {
386        // (initial_text, initial_cursor, key_event, expected_cursor)
387        let cases: Vec<(&str, Option<usize>, KeyEvent, usize)> = vec![
388            ("hello", None, key(KeyCode::Left), 4),
389            ("hello", None, key(KeyCode::Right), 5),
390            ("", None, key(KeyCode::Left), 0),
391            ("hello", None, key(KeyCode::Home), 0),
392            ("hello", Some(0), key(KeyCode::End), 5),
393            ("hello", None, ctrl(KeyCode::Char('a')), 0),
394            ("hello", Some(0), ctrl(KeyCode::Char('e')), 5),
395        ];
396        for (text, cursor, k, expected) in cases {
397            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
398            send_key(&mut f, k).await;
399            assert_eq!(f.cursor_pos(), expected, "failed for key {k:?} on {text:?}");
400        }
401    }
402
403    #[tokio::test]
404    async fn insert_at_middle() {
405        let mut f = field_at("hllo", 1);
406        send_key(&mut f, key(KeyCode::Char('e'))).await;
407        assert_state(&f, "hello", 2);
408    }
409
410    #[tokio::test]
411    async fn multibyte_utf8_navigation() {
412        let mut f = field("a中b");
413        assert_eq!(f.cursor_pos(), 5);
414        for expected in [4, 1, 0] {
415            send_key(&mut f, key(KeyCode::Left)).await;
416            assert_eq!(f.cursor_pos(), expected);
417        }
418        for expected in [1, 4] {
419            send_key(&mut f, key(KeyCode::Right)).await;
420            assert_eq!(f.cursor_pos(), expected);
421        }
422    }
423
424    #[test]
425    fn set_value_moves_cursor_to_end() {
426        let mut f = field("");
427        f.set_value("hello".to_string());
428        assert_state(&f, "hello", 5);
429    }
430
431    #[test]
432    fn clear_resets_cursor() {
433        let mut f = field("hello");
434        f.clear();
435        assert_state(&f, "", 0);
436    }
437
438    #[tokio::test]
439    async fn delete_forward_variants() {
440        // Middle of string
441        let mut f = field_at("hello", 2);
442        send_key(&mut f, key(KeyCode::Delete)).await;
443        assert_state(&f, "helo", 2);
444
445        // At end (no-op)
446        let mut f = field("hello");
447        send_key(&mut f, key(KeyCode::Delete)).await;
448        assert_eq!(f.value, "hello");
449
450        // Multibyte character
451        let mut f = field_at("a中b", 1);
452        send_key(&mut f, key(KeyCode::Delete)).await;
453        assert_state(&f, "ab", 1);
454    }
455
456    #[tokio::test]
457    async fn ctrl_w_variants() {
458        // (text, cursor, expected_value, expected_cursor)
459        let cases: Vec<(&str, Option<usize>, &str, usize)> = vec![
460            ("hello world", None, "hello ", 6),
461            ("hello   ", None, "", 0),
462            ("hello", Some(0), "hello", 0),
463            ("hello world", Some(8), "hello rld", 6),
464            ("", None, "", 0),
465        ];
466        for (text, cursor, exp_val, exp_cur) in cases {
467            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
468            send_key(&mut f, ctrl(KeyCode::Char('w'))).await;
469            assert_state(&f, exp_val, exp_cur);
470        }
471    }
472
473    #[tokio::test]
474    async fn alt_backspace_deletes_word() {
475        let mut f = field("hello world");
476        send_key(&mut f, alt(KeyCode::Backspace)).await;
477        assert_eq!(f.value, "hello ");
478    }
479
480    #[tokio::test]
481    async fn ctrl_u_variants() {
482        let mut f = field_at("hello world", 5);
483        send_key(&mut f, ctrl(KeyCode::Char('u'))).await;
484        assert_state(&f, " world", 0);
485
486        // At start (no-op)
487        let mut f = field_at("hello", 0);
488        send_key(&mut f, ctrl(KeyCode::Char('u'))).await;
489        assert_state(&f, "hello", 0);
490    }
491
492    #[tokio::test]
493    async fn ctrl_k_variants() {
494        let mut f = field_at("hello world", 5);
495        send_key(&mut f, ctrl(KeyCode::Char('k'))).await;
496        assert_state(&f, "hello", 5);
497
498        // At end (no-op)
499        let mut f = field("hello");
500        send_key(&mut f, ctrl(KeyCode::Char('k'))).await;
501        assert_eq!(f.value, "hello");
502    }
503
504    #[tokio::test]
505    async fn word_navigation() {
506        // (text, cursor, key_event, expected_cursor)
507        let cases: Vec<(&str, Option<usize>, KeyEvent, usize)> = vec![
508            ("hello world", None, alt(KeyCode::Left), 6),
509            ("hello world", Some(8), alt(KeyCode::Left), 6),
510            ("hello", Some(0), alt(KeyCode::Left), 0),
511            ("hello world", None, ctrl(KeyCode::Left), 6),
512            ("hello world", Some(0), alt(KeyCode::Right), 6),
513            ("hello", None, alt(KeyCode::Right), 5),
514            ("a中 b", Some(0), alt(KeyCode::Right), 5),
515            ("hello world", Some(0), ctrl(KeyCode::Right), 6),
516        ];
517        for (text, cursor, k, expected) in cases {
518            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
519            send_key(&mut f, k).await;
520            assert_eq!(f.cursor_pos(), expected, "failed for {k:?} on {text:?} at {cursor:?}");
521        }
522    }
523
524    #[test]
525    fn move_cursor_up_cases() {
526        // (text, cursor, width, expected)
527        let cases: Vec<(&str, Option<usize>, usize, usize)> = vec![
528            ("hello world", Some(3), 10, 0), // first row goes home
529            ("hello world", Some(8), 5, 3),  // multi-row: row1->row0
530            ("hello", Some(3), 0, 3),        // zero width is no-op
531        ];
532        for (text, cursor, width, expected) in cases {
533            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
534            f.move_cursor_up(width);
535            assert_eq!(f.cursor_pos(), expected, "up failed: {text:?} cursor={cursor:?} w={width}");
536        }
537    }
538
539    #[test]
540    fn move_cursor_up_wide_chars() {
541        // '中' is 2 display-width columns
542        // "中中中中中" = 10 display cols, content_width=5 -> 2 rows
543        // Cursor at end: byte 15, display 10, row 2, col 0
544        // byte_offset_for_display_width(5): wide chars can't land exactly on boundary
545        let mut f = field("中中中中中");
546        f.move_cursor_up(5);
547        assert_eq!(f.cursor_pos(), 9);
548    }
549
550    #[test]
551    fn move_cursor_down_cases() {
552        // (text, cursor, width, expected)
553        let cases: Vec<(&str, Option<usize>, usize, usize)> = vec![
554            ("hello world", Some(0), 20, 11), // last row goes end
555            ("hello world", Some(3), 5, 8),   // multi-row: row0->row1
556            ("hello world", Some(8), 5, 11),  // clamps to total width
557            ("", None, 10, 0),                // empty string
558        ];
559        for (text, cursor, width, expected) in cases {
560            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
561            f.move_cursor_down(width);
562            assert_eq!(f.cursor_pos(), expected, "down failed: {text:?} cursor={cursor:?} w={width}");
563        }
564    }
565
566    #[test]
567    fn is_cursor_on_first_visual_line() {
568        // "hello world" with width 5 → row 0: "hello", row 1: " worl", row 2: "d"
569        let mut f = field_at("hello world", 3);
570        f.set_content_width(5);
571        assert!(f.is_cursor_on_first_visual_line()); // cursor on row 0
572
573        let mut f = field_at("hello world", 8);
574        f.set_content_width(5);
575        assert!(!f.is_cursor_on_first_visual_line()); // cursor on row 1
576    }
577
578    #[test]
579    fn is_cursor_on_last_visual_line() {
580        // "hello world" with width 5 → row 0: "hello", row 1: " worl", row 2: "d"
581        let mut f = field_at("hello world", 11);
582        f.set_content_width(5);
583        assert!(f.is_cursor_on_last_visual_line()); // cursor at end, row 2
584
585        let mut f = field_at("hello world", 3);
586        f.set_content_width(5);
587        assert!(!f.is_cursor_on_last_visual_line()); // cursor on row 0
588
589        let mut f = field_at("hello world", 8);
590        f.set_content_width(5);
591        assert!(!f.is_cursor_on_last_visual_line()); // cursor on row 1
592    }
593
594    #[test]
595    fn single_line_is_both_first_and_last() {
596        let mut f = field_at("hello", 3);
597        f.set_content_width(20);
598        assert!(f.is_cursor_on_first_visual_line());
599        assert!(f.is_cursor_on_last_visual_line());
600    }
601
602    #[test]
603    fn empty_field_is_both_first_and_last() {
604        let mut f = field("");
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}