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    fn display_width_up_to(&self, byte_pos: usize) -> usize {
158        display_width_text(&self.value[..byte_pos])
159    }
160
161    fn byte_offset_for_display_width(&self, target_width: usize) -> usize {
162        let mut width = 0;
163        for (i, ch) in self.value.char_indices() {
164            if width >= target_width {
165                return i;
166            }
167            width += UnicodeWidthChar::width(ch).unwrap_or(0);
168        }
169        self.value.len()
170    }
171}
172
173impl Component for TextField {
174    type Message = ();
175
176    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
177        match event {
178            Event::Key(key) => {
179                let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
180                let alt = key.modifiers.contains(KeyModifiers::ALT);
181                match key.code {
182                    KeyCode::Char('a') if ctrl => {
183                        self.cursor_pos = 0;
184                        Some(vec![])
185                    }
186                    KeyCode::Char('e') if ctrl => {
187                        self.cursor_pos = self.value.len();
188                        Some(vec![])
189                    }
190                    KeyCode::Char('w') if ctrl => {
191                        self.delete_word_backward();
192                        Some(vec![])
193                    }
194                    KeyCode::Char('u') if ctrl => {
195                        self.value.drain(..self.cursor_pos);
196                        self.cursor_pos = 0;
197                        Some(vec![])
198                    }
199                    KeyCode::Char('k') if ctrl => {
200                        self.value.truncate(self.cursor_pos);
201                        Some(vec![])
202                    }
203                    KeyCode::Backspace if alt => {
204                        self.delete_word_backward();
205                        Some(vec![])
206                    }
207                    KeyCode::Left if alt || ctrl => {
208                        self.cursor_pos = self.word_start_backward();
209                        Some(vec![])
210                    }
211                    KeyCode::Right if alt || ctrl => {
212                        self.word_end_forward();
213                        Some(vec![])
214                    }
215                    KeyCode::Delete => {
216                        self.delete_after_cursor();
217                        Some(vec![])
218                    }
219                    KeyCode::Char(c) if !ctrl => {
220                        self.insert_at_cursor(c);
221                        Some(vec![])
222                    }
223                    KeyCode::Backspace => {
224                        self.delete_before_cursor();
225                        Some(vec![])
226                    }
227                    KeyCode::Left => {
228                        self.cursor_pos =
229                            self.value[..self.cursor_pos].char_indices().next_back().map_or(0, |(i, _)| i);
230                        Some(vec![])
231                    }
232                    KeyCode::Right => {
233                        if let Some(c) = self.value[self.cursor_pos..].chars().next() {
234                            self.cursor_pos += c.len_utf8();
235                        }
236                        Some(vec![])
237                    }
238                    KeyCode::Home => {
239                        self.cursor_pos = 0;
240                        Some(vec![])
241                    }
242                    KeyCode::End => {
243                        self.cursor_pos = self.value.len();
244                        Some(vec![])
245                    }
246                    KeyCode::Up => {
247                        self.move_cursor_up(self.content_width);
248                        Some(vec![])
249                    }
250                    KeyCode::Down => {
251                        self.move_cursor_down(self.content_width);
252                        Some(vec![])
253                    }
254                    _ => None,
255                }
256            }
257            Event::Paste(text) => {
258                self.insert_str_at_cursor(text);
259                Some(vec![])
260            }
261            _ => None,
262        }
263    }
264
265    fn render(&mut self, context: &ViewContext) -> Frame {
266        Frame::new(self.render_field(context, true))
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crossterm::event::{KeyEvent, KeyModifiers};
274
275    fn key(code: KeyCode) -> KeyEvent {
276        KeyEvent::new(code, KeyModifiers::NONE)
277    }
278    fn ctrl(code: KeyCode) -> KeyEvent {
279        KeyEvent::new(code, KeyModifiers::CONTROL)
280    }
281    fn alt(code: KeyCode) -> KeyEvent {
282        KeyEvent::new(code, KeyModifiers::ALT)
283    }
284    fn field(text: &str) -> TextField {
285        TextField::new(text.to_string())
286    }
287    fn field_at(text: &str, cursor: usize) -> TextField {
288        let mut f = field(text);
289        f.set_cursor_pos(cursor);
290        f
291    }
292
293    async fn send(f: &mut TextField, evt: Event) -> Option<Vec<()>> {
294        f.on_event(&evt).await
295    }
296    async fn send_key(f: &mut TextField, k: KeyEvent) -> Option<Vec<()>> {
297        send(f, Event::Key(k)).await
298    }
299
300    /// Assert both value and cursor position.
301    fn assert_state(f: &TextField, value: &str, cursor: usize) {
302        assert_eq!(f.value, value, "value mismatch");
303        assert_eq!(f.cursor_pos(), cursor, "cursor mismatch");
304    }
305
306    #[tokio::test]
307    async fn typing_appends_characters() {
308        let mut f = field("");
309        send_key(&mut f, key(KeyCode::Char('h'))).await;
310        send_key(&mut f, key(KeyCode::Char('i'))).await;
311        assert_eq!(f.value, "hi");
312    }
313
314    #[tokio::test]
315    async fn backspace_variants() {
316        // End of string
317        let mut f = field("abc");
318        send_key(&mut f, key(KeyCode::Backspace)).await;
319        assert_eq!(f.value, "ab");
320
321        // Empty string (no-op)
322        let mut f = field("");
323        send_key(&mut f, key(KeyCode::Backspace)).await;
324        assert_eq!(f.value, "");
325
326        // Middle of string
327        let mut f = field_at("hello", 3);
328        send_key(&mut f, key(KeyCode::Backspace)).await;
329        assert_state(&f, "helo", 2);
330    }
331
332    #[test]
333    fn to_json_returns_string_value() {
334        assert_eq!(field("hello").to_json(), serde_json::json!("hello"));
335    }
336
337    #[tokio::test]
338    async fn unhandled_keys_are_ignored() {
339        let mut f = field("");
340        assert!(send_key(&mut f, key(KeyCode::F(1))).await.is_none());
341    }
342
343    #[tokio::test]
344    async fn paste_variants() {
345        // Into empty field
346        let mut f = field("");
347        let outcome = send(&mut f, Event::Paste("hello".into())).await;
348        assert!(outcome.is_some());
349        assert_eq!(f.value, "hello");
350
351        // At cursor position
352        let mut f = field_at("hd", 1);
353        send(&mut f, Event::Paste("ello worl".into())).await;
354        assert_state(&f, "hello world", 10);
355    }
356
357    #[test]
358    fn cursor_starts_at_end() {
359        assert_eq!(field("hello").cursor_pos(), 5);
360    }
361
362    #[tokio::test]
363    async fn cursor_movement_single_keys() {
364        // (initial_text, initial_cursor, key_event, expected_cursor)
365        let cases: Vec<(&str, Option<usize>, KeyEvent, usize)> = vec![
366            ("hello", None, key(KeyCode::Left), 4),
367            ("hello", None, key(KeyCode::Right), 5),
368            ("", None, key(KeyCode::Left), 0),
369            ("hello", None, key(KeyCode::Home), 0),
370            ("hello", Some(0), key(KeyCode::End), 5),
371            ("hello", None, ctrl(KeyCode::Char('a')), 0),
372            ("hello", Some(0), ctrl(KeyCode::Char('e')), 5),
373        ];
374        for (text, cursor, k, expected) in cases {
375            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
376            send_key(&mut f, k).await;
377            assert_eq!(f.cursor_pos(), expected, "failed for key {k:?} on {text:?}");
378        }
379    }
380
381    #[tokio::test]
382    async fn insert_at_middle() {
383        let mut f = field_at("hllo", 1);
384        send_key(&mut f, key(KeyCode::Char('e'))).await;
385        assert_state(&f, "hello", 2);
386    }
387
388    #[tokio::test]
389    async fn multibyte_utf8_navigation() {
390        let mut f = field("a中b");
391        assert_eq!(f.cursor_pos(), 5);
392        for expected in [4, 1, 0] {
393            send_key(&mut f, key(KeyCode::Left)).await;
394            assert_eq!(f.cursor_pos(), expected);
395        }
396        for expected in [1, 4] {
397            send_key(&mut f, key(KeyCode::Right)).await;
398            assert_eq!(f.cursor_pos(), expected);
399        }
400    }
401
402    #[test]
403    fn set_value_moves_cursor_to_end() {
404        let mut f = field("");
405        f.set_value("hello".to_string());
406        assert_state(&f, "hello", 5);
407    }
408
409    #[test]
410    fn clear_resets_cursor() {
411        let mut f = field("hello");
412        f.clear();
413        assert_state(&f, "", 0);
414    }
415
416    #[tokio::test]
417    async fn delete_forward_variants() {
418        // Middle of string
419        let mut f = field_at("hello", 2);
420        send_key(&mut f, key(KeyCode::Delete)).await;
421        assert_state(&f, "helo", 2);
422
423        // At end (no-op)
424        let mut f = field("hello");
425        send_key(&mut f, key(KeyCode::Delete)).await;
426        assert_eq!(f.value, "hello");
427
428        // Multibyte character
429        let mut f = field_at("a中b", 1);
430        send_key(&mut f, key(KeyCode::Delete)).await;
431        assert_state(&f, "ab", 1);
432    }
433
434    #[tokio::test]
435    async fn ctrl_w_variants() {
436        // (text, cursor, expected_value, expected_cursor)
437        let cases: Vec<(&str, Option<usize>, &str, usize)> = vec![
438            ("hello world", None, "hello ", 6),
439            ("hello   ", None, "", 0),
440            ("hello", Some(0), "hello", 0),
441            ("hello world", Some(8), "hello rld", 6),
442            ("", None, "", 0),
443        ];
444        for (text, cursor, exp_val, exp_cur) in cases {
445            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
446            send_key(&mut f, ctrl(KeyCode::Char('w'))).await;
447            assert_state(&f, exp_val, exp_cur);
448        }
449    }
450
451    #[tokio::test]
452    async fn alt_backspace_deletes_word() {
453        let mut f = field("hello world");
454        send_key(&mut f, alt(KeyCode::Backspace)).await;
455        assert_eq!(f.value, "hello ");
456    }
457
458    #[tokio::test]
459    async fn ctrl_u_variants() {
460        let mut f = field_at("hello world", 5);
461        send_key(&mut f, ctrl(KeyCode::Char('u'))).await;
462        assert_state(&f, " world", 0);
463
464        // At start (no-op)
465        let mut f = field_at("hello", 0);
466        send_key(&mut f, ctrl(KeyCode::Char('u'))).await;
467        assert_state(&f, "hello", 0);
468    }
469
470    #[tokio::test]
471    async fn ctrl_k_variants() {
472        let mut f = field_at("hello world", 5);
473        send_key(&mut f, ctrl(KeyCode::Char('k'))).await;
474        assert_state(&f, "hello", 5);
475
476        // At end (no-op)
477        let mut f = field("hello");
478        send_key(&mut f, ctrl(KeyCode::Char('k'))).await;
479        assert_eq!(f.value, "hello");
480    }
481
482    #[tokio::test]
483    async fn word_navigation() {
484        // (text, cursor, key_event, expected_cursor)
485        let cases: Vec<(&str, Option<usize>, KeyEvent, usize)> = vec![
486            ("hello world", None, alt(KeyCode::Left), 6),
487            ("hello world", Some(8), alt(KeyCode::Left), 6),
488            ("hello", Some(0), alt(KeyCode::Left), 0),
489            ("hello world", None, ctrl(KeyCode::Left), 6),
490            ("hello world", Some(0), alt(KeyCode::Right), 6),
491            ("hello", None, alt(KeyCode::Right), 5),
492            ("a中 b", Some(0), alt(KeyCode::Right), 5),
493            ("hello world", Some(0), ctrl(KeyCode::Right), 6),
494        ];
495        for (text, cursor, k, expected) in cases {
496            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
497            send_key(&mut f, k).await;
498            assert_eq!(f.cursor_pos(), expected, "failed for {k:?} on {text:?} at {cursor:?}");
499        }
500    }
501
502    #[test]
503    fn move_cursor_up_cases() {
504        // (text, cursor, width, expected)
505        let cases: Vec<(&str, Option<usize>, usize, usize)> = vec![
506            ("hello world", Some(3), 10, 0), // first row goes home
507            ("hello world", Some(8), 5, 3),  // multi-row: row1->row0
508            ("hello", Some(3), 0, 3),        // zero width is no-op
509        ];
510        for (text, cursor, width, expected) in cases {
511            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
512            f.move_cursor_up(width);
513            assert_eq!(f.cursor_pos(), expected, "up failed: {text:?} cursor={cursor:?} w={width}");
514        }
515    }
516
517    #[test]
518    fn move_cursor_up_wide_chars() {
519        // '中' is 2 display-width columns
520        // "中中中中中" = 10 display cols, content_width=5 -> 2 rows
521        // Cursor at end: byte 15, display 10, row 2, col 0
522        // byte_offset_for_display_width(5): wide chars can't land exactly on boundary
523        let mut f = field("中中中中中");
524        f.move_cursor_up(5);
525        assert_eq!(f.cursor_pos(), 9);
526    }
527
528    #[test]
529    fn move_cursor_down_cases() {
530        // (text, cursor, width, expected)
531        let cases: Vec<(&str, Option<usize>, usize, usize)> = vec![
532            ("hello world", Some(0), 20, 11), // last row goes end
533            ("hello world", Some(3), 5, 8),   // multi-row: row0->row1
534            ("hello world", Some(8), 5, 11),  // clamps to total width
535            ("", None, 10, 0),                // empty string
536        ];
537        for (text, cursor, width, expected) in cases {
538            let mut f = cursor.map_or_else(|| field(text), |c| field_at(text, c));
539            f.move_cursor_down(width);
540            assert_eq!(f.cursor_pos(), expected, "down failed: {text:?} cursor={cursor:?} w={width}");
541        }
542    }
543}