Skip to main content

wisp/components/
text_input.rs

1use crate::keybindings::Keybindings;
2use std::path::PathBuf;
3use tui::{Component, Event, Frame, KeyEvent, Line, TextField, ViewContext};
4
5#[doc = include_str!("../docs/text_input.md")]
6pub struct TextInput {
7    field: TextField,
8    mentions: Vec<SelectedFileMention>,
9    keybindings: Keybindings,
10}
11
12pub enum TextInputMessage {
13    Submit,
14    OpenCommandPicker,
15    OpenFilePicker,
16}
17
18#[derive(Debug, Clone)]
19pub struct SelectedFileMention {
20    pub mention: String,
21    pub path: PathBuf,
22    pub display_name: String,
23}
24
25impl Default for TextInput {
26    fn default() -> Self {
27        Self::new(Keybindings::default())
28    }
29}
30
31impl TextInput {
32    pub fn new(keybindings: Keybindings) -> Self {
33        Self { field: TextField::new(String::new()), mentions: Vec::new(), keybindings }
34    }
35
36    pub fn set_content_width(&mut self, width: usize) {
37        self.field.set_content_width(width);
38    }
39
40    pub fn buffer(&self) -> &str {
41        &self.field.value
42    }
43
44    /// Returns the visual cursor index, accounting for an active file picker
45    /// whose query extends beyond the `@` trigger character.
46    pub fn cursor_index(&self, picker_query_len: Option<usize>) -> usize {
47        if let Some(query_len) = picker_query_len {
48            let at_pos = self.active_mention_start().unwrap_or(self.field.value.len());
49            at_pos + 1 + query_len
50        } else {
51            self.field.cursor_pos()
52        }
53    }
54
55    #[cfg(test)]
56    pub fn mentions(&self) -> &[SelectedFileMention] {
57        &self.mentions
58    }
59
60    pub fn take_mentions(&mut self) -> Vec<SelectedFileMention> {
61        std::mem::take(&mut self.mentions)
62    }
63
64    pub fn set_input(&mut self, s: String) {
65        self.field.set_value(s);
66    }
67
68    #[cfg(test)]
69    pub fn set_cursor_pos(&mut self, pos: usize) {
70        self.field.set_cursor_pos(pos);
71    }
72
73    pub fn clear(&mut self) {
74        self.field.clear();
75    }
76
77    pub fn insert_char_at_cursor(&mut self, c: char) {
78        self.field.insert_at_cursor(c);
79    }
80
81    pub fn delete_char_before_cursor(&mut self) -> bool {
82        self.field.delete_before_cursor()
83    }
84
85    pub fn insert_paste(&mut self, text: &str) {
86        let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
87        self.field.insert_str_at_cursor(&filtered);
88    }
89
90    pub fn apply_file_selection(&mut self, path: PathBuf, display_name: String) {
91        let mention = format!("@{display_name}");
92        self.mentions.push(SelectedFileMention { mention: mention.clone(), path, display_name });
93
94        if let Some(at_pos) = self.active_mention_start() {
95            let mut s = self.field.value[..at_pos].to_string();
96            s.push_str(&mention);
97            s.push(' ');
98            self.set_input(s);
99        }
100    }
101
102    fn active_mention_start(&self) -> Option<usize> {
103        mention_start(&self.field.value)
104    }
105}
106
107impl Component for TextInput {
108    type Message = TextInputMessage;
109
110    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
111        match event {
112            Event::Paste(text) => {
113                self.insert_paste(text);
114                Some(vec![])
115            }
116            Event::Key(key_event) => self.handle_key(key_event).await,
117            _ => None,
118        }
119    }
120
121    fn render(&mut self, _context: &ViewContext) -> Frame {
122        Frame::new(vec![Line::new(self.field.value.clone())])
123    }
124}
125
126impl TextInput {
127    async fn handle_key(&mut self, key_event: &KeyEvent) -> Option<Vec<TextInputMessage>> {
128        if self.keybindings.submit.matches(*key_event) {
129            return Some(vec![TextInputMessage::Submit]);
130        }
131
132        if self.keybindings.open_command_picker.matches(*key_event) && self.field.value.is_empty() {
133            if let Some(c) = self.keybindings.open_command_picker.char() {
134                self.field.insert_at_cursor(c);
135            }
136            return Some(vec![TextInputMessage::OpenCommandPicker]);
137        }
138
139        if self.keybindings.open_file_picker.matches(*key_event) {
140            if let Some(c) = self.keybindings.open_file_picker.char() {
141                self.field.insert_at_cursor(c);
142            }
143            return Some(vec![TextInputMessage::OpenFilePicker]);
144        }
145
146        // Delegate cursor navigation, char input, and backspace to TextField
147        self.field.on_event(&Event::Key(*key_event)).await.map(|_| vec![])
148    }
149}
150
151fn mention_start(input: &str) -> Option<usize> {
152    let at_pos = input.rfind('@')?;
153    let prefix = &input[..at_pos];
154    if prefix.is_empty() || prefix.chars().last().is_some_and(char::is_whitespace) { Some(at_pos) } else { None }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use tui::KeyCode;
161    use tui::KeyModifiers;
162
163    fn key(code: KeyCode) -> Event {
164        Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
165    }
166
167    fn input_with(text: &str, cursor: Option<usize>) -> TextInput {
168        let mut input = TextInput::default();
169        input.set_input(text.to_string());
170        if let Some(pos) = cursor {
171            input.set_cursor_pos(pos);
172        }
173        input
174    }
175
176    fn input_with_width(text: &str, cursor: usize, width: usize) -> TextInput {
177        let mut input = TextInput::default();
178        input.set_content_width(width);
179        input.set_input(text.to_string());
180        input.set_cursor_pos(cursor);
181        input
182    }
183
184    fn cursor(input: &TextInput) -> usize {
185        input.cursor_index(None)
186    }
187
188    #[tokio::test]
189    async fn arrow_key_cursor_movement() {
190        // (initial_text, initial_cursor, key_code, expected_cursor)
191        let cases = [
192            ("hello", None, KeyCode::Left, 4, "left from end"),
193            ("hello", Some(2), KeyCode::Right, 3, "right from middle"),
194            ("hello", Some(0), KeyCode::Left, 0, "left at start stays"),
195            ("hello", None, KeyCode::Right, 5, "right at end stays"),
196            ("hello", Some(3), KeyCode::Home, 0, "home moves to start"),
197            ("hello", Some(1), KeyCode::End, 5, "end moves to end"),
198        ];
199        for (text, cur, code, expected, label) in cases {
200            let mut input = input_with(text, cur);
201            input.on_event(&key(code)).await;
202            assert_eq!(cursor(&input), expected, "{label}");
203        }
204    }
205
206    #[tokio::test]
207    async fn typing_inserts_at_cursor_position() {
208        let mut input = input_with("hllo", Some(1));
209        input.on_event(&key(KeyCode::Char('e'))).await;
210        assert_eq!(input.buffer(), "hello");
211        assert_eq!(cursor(&input), 2);
212    }
213
214    #[tokio::test]
215    async fn backspace_at_cursor_middle_deletes_correct_char() {
216        let mut input = input_with("hello", Some(3));
217        input.on_event(&key(KeyCode::Backspace)).await;
218        assert_eq!(input.buffer(), "helo");
219        assert_eq!(cursor(&input), 2);
220    }
221
222    #[tokio::test]
223    async fn backspace_at_start_does_nothing() {
224        let mut input = input_with("hello", Some(0));
225        let outcome = input.on_event(&key(KeyCode::Backspace)).await;
226        assert!(outcome.is_some());
227        assert_eq!(input.buffer(), "hello");
228        assert_eq!(cursor(&input), 0);
229    }
230
231    #[tokio::test]
232    async fn multibyte_utf8_cursor_navigation() {
233        // "a中b" — 'a' is 1 byte, '中' is 3 bytes, 'b' is 1 byte = 5 bytes total
234        let mut input = input_with("a中b", None);
235
236        let steps: &[(KeyCode, usize)] = &[
237            (KeyCode::Left, 4),  // before 'b'
238            (KeyCode::Left, 1),  // before '中'
239            (KeyCode::Left, 0),  // before 'a'
240            (KeyCode::Right, 1), // after 'a'
241            (KeyCode::Right, 4), // after '中'
242        ];
243        for (code, expected) in steps {
244            input.on_event(&key(*code)).await;
245            assert_eq!(cursor(&input), *expected);
246        }
247    }
248
249    #[test]
250    fn paste_inserts_at_cursor_position() {
251        let mut input = input_with("hd", Some(1));
252        input.insert_paste("ello worl");
253        assert_eq!(input.buffer(), "hello world");
254        assert_eq!(cursor(&input), 10);
255    }
256
257    #[tokio::test]
258    async fn slash_on_empty_returns_open_command_picker() {
259        let mut input = TextInput::default();
260        let outcome = input.on_event(&key(KeyCode::Char('/'))).await;
261        assert!(matches!(outcome.as_deref(), Some([TextInputMessage::OpenCommandPicker])));
262        assert_eq!(input.buffer(), "/");
263    }
264
265    #[tokio::test]
266    async fn at_sign_returns_open_file_picker() {
267        let mut input = TextInput::default();
268        let outcome = input.on_event(&key(KeyCode::Char('@'))).await;
269        assert!(matches!(outcome.as_deref(), Some([TextInputMessage::OpenFilePicker])));
270        assert_eq!(input.buffer(), "@");
271    }
272
273    #[tokio::test]
274    async fn enter_returns_submit() {
275        let mut input = input_with("hello", None);
276        let outcome = input.on_event(&key(KeyCode::Enter)).await;
277        assert!(matches!(outcome.as_deref(), Some([TextInputMessage::Submit])));
278    }
279
280    #[test]
281    fn file_selection_updates_mentions_and_buffer() {
282        let mut input = input_with("@fo", None);
283        input.apply_file_selection(PathBuf::from("foo.rs"), "foo.rs".to_string());
284        assert_eq!(input.buffer(), "@foo.rs ");
285        assert_eq!(input.mentions().len(), 1);
286        assert_eq!(input.mentions()[0].mention, "@foo.rs");
287    }
288
289    #[test]
290    fn cursor_index_with_and_without_picker() {
291        let input = input_with("hello", Some(3));
292        assert_eq!(input.cursor_index(None), 3);
293
294        let input = input_with("@fo", None);
295        // Picker has 2-char query ("fo"), @ is at position 0
296        assert_eq!(input.cursor_index(Some(2)), 3); // 0 + 1 + 2
297    }
298
299    #[test]
300    fn clear_resets_buffer_and_cursor() {
301        let mut input = input_with("hello", None);
302        input.clear();
303        assert_eq!(input.buffer(), "");
304        assert_eq!(cursor(&input), 0);
305    }
306
307    #[tokio::test]
308    async fn vertical_cursor_movement_in_wrapped_text() {
309        // "hello world" with width 5 → row 0: "hello", row 1: " worl", row 2: "d"
310        // (cursor, key, expected, label)
311        let cases = [
312            (8, KeyCode::Up, 3, "up from row 1 col 3 -> row 0 col 3"),
313            (3, KeyCode::Down, 8, "down from row 0 col 3 -> row 1 col 3"),
314        ];
315        for (cur, code, expected, label) in cases {
316            let mut input = input_with_width("hello world", cur, 5);
317            input.on_event(&key(code)).await;
318            assert_eq!(cursor(&input), expected, "{label}");
319        }
320    }
321
322    #[tokio::test]
323    async fn up_on_first_row_goes_home_down_on_last_row_goes_end() {
324        let cases =
325            [(3, KeyCode::Up, 0, "up on single row -> home"), (0, KeyCode::Down, 5, "down on single row -> end")];
326        for (cur, code, expected, label) in cases {
327            let mut input = input_with_width("hello", cur, 20);
328            input.on_event(&key(code)).await;
329            assert_eq!(cursor(&input), expected, "{label}");
330        }
331    }
332}