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