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 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 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 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 let mut input = input_with("a中b", None);
251
252 let steps: &[(KeyCode, usize)] = &[
253 (KeyCode::Left, 4), (KeyCode::Left, 1), (KeyCode::Left, 0), (KeyCode::Right, 1), (KeyCode::Right, 4), ];
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 assert_eq!(input.cursor_index(Some(2)), 3); }
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 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}