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