1use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, MouseEvent};
4use ratatui::{
5 Frame,
6 layout::{Position, Rect},
7 style::Style,
8 widgets::{Block, Paragraph},
9};
10use tokio::sync::mpsc::UnboundedSender;
11
12use crate::{
13 state::action::Action,
14 ui::{
15 AppState,
16 components::{Component, ComponentRender},
17 },
18};
19
20#[derive(Debug)]
21pub struct InputBox {
22 text: String,
24 cursor_position: usize,
26}
27
28impl InputBox {
29 #[must_use]
30 #[allow(clippy::missing_const_for_fn)] pub fn text(&self) -> &str {
32 &self.text
33 }
34
35 pub fn set_text(&mut self, new_text: &str) {
36 self.text = String::from(new_text);
37 self.cursor_position = self.text.len();
38 }
39
40 pub fn reset(&mut self) {
41 self.cursor_position = 0;
42 self.text.clear();
43 }
44
45 #[must_use]
46 pub const fn is_empty(&self) -> bool {
47 self.text.is_empty()
48 }
49
50 fn move_cursor_left(&mut self) {
51 let cursor_moved_left = self.cursor_position.saturating_sub(1);
52 self.cursor_position = self.clamp_cursor(cursor_moved_left);
53 }
54
55 fn move_cursor_right(&mut self) {
56 let cursor_moved_right = self.cursor_position.saturating_add(1);
57 self.cursor_position = self.clamp_cursor(cursor_moved_right);
58 }
59
60 fn enter_char(&mut self, new_char: char) {
61 let cursor_byte_index = self
64 .text
65 .chars()
66 .take(self.cursor_position)
67 .map(char::len_utf8)
68 .sum();
69
70 self.text.insert(cursor_byte_index, new_char);
71
72 self.move_cursor_right();
73 }
74
75 fn delete_char(&mut self) {
77 if self.cursor_position == 0 {
78 return;
79 }
80
81 let current_index = self.cursor_position;
86 let from_left_to_current_index = current_index - 1;
87
88 let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
90 let after_char_to_delete = self.text.chars().skip(current_index);
92
93 self.text = before_char_to_delete.chain(after_char_to_delete).collect();
96 self.move_cursor_left();
97 }
98
99 fn delete_next_char(&mut self) {
101 let before_cursor = self.text.chars().take(self.cursor_position);
104 let after_cursor = self.text.chars().skip(self.cursor_position + 1);
105 self.text = before_cursor.chain(after_cursor).collect();
106 }
107
108 fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
109 new_cursor_pos.clamp(0, self.text.len())
110 }
111}
112
113impl Component for InputBox {
114 fn new(_state: &AppState, _action_tx: UnboundedSender<Action>) -> Self {
115 Self {
116 text: String::new(),
118 cursor_position: 0,
119 }
120 }
121
122 fn move_with_state(self, _state: &AppState) -> Self
123 where
124 Self: Sized,
125 {
126 self
127 }
128
129 fn name(&self) -> &'static str {
130 "Input Box"
131 }
132
133 fn handle_key_event(&mut self, key: KeyEvent) {
134 if key.kind != KeyEventKind::Press {
135 return;
136 }
137
138 match key.code {
139 KeyCode::Char(to_insert) => {
140 self.enter_char(to_insert);
141 }
142 KeyCode::Backspace => {
143 self.delete_char();
144 }
145 KeyCode::Delete => {
146 self.delete_next_char();
147 }
148 KeyCode::Left => {
149 self.move_cursor_left();
150 }
151 KeyCode::Right => {
152 self.move_cursor_right();
153 }
154 KeyCode::Home => {
155 self.cursor_position = 0;
156 }
157 KeyCode::End => {
158 self.cursor_position = self.text.len();
159 }
160 _ => {}
161 }
162 }
163
164 fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
168 let MouseEvent {
169 kind, column, row, ..
170 } = mouse;
171 let mouse_position = Position::new(column, row);
172
173 if !area.contains(mouse_position) {
174 return;
175 }
176
177 if kind == crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) {
178 let mouse_x = mouse_position.x.saturating_sub(area.x + 1) as usize;
180
181 self.cursor_position = self.clamp_cursor(mouse_x);
182 }
183 }
184}
185
186#[derive(Debug, Clone)]
187pub struct RenderProps<'a> {
188 pub border: Block<'a>,
189 pub area: Rect,
190 pub text_color: ratatui::style::Color,
191 pub show_cursor: bool,
192}
193
194impl<'a> ComponentRender<RenderProps<'a>> for InputBox {
195 fn render_border(&self, frame: &mut Frame<'_>, props: RenderProps<'a>) -> RenderProps<'a> {
196 let view_area = props.border.inner(props.area);
197 frame.render_widget(&props.border, props.area);
198 RenderProps {
199 area: view_area,
200 ..props
201 }
202 }
203
204 fn render_content(&self, frame: &mut Frame<'_>, props: RenderProps<'a>) {
205 let input = Paragraph::new(self.text.as_str()).style(Style::default().fg(props.text_color));
206 frame.render_widget(input, props.area);
207
208 if props.show_cursor {
210 #[allow(clippy::cast_possible_truncation)]
213 frame.set_cursor_position(
214 (props.area.x + self.cursor_position as u16, props.area.y),
217 );
218 }
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use crate::test_utils::setup_test_terminal;
225
226 use super::*;
227 use anyhow::Result;
228 use pretty_assertions::assert_eq;
229 use ratatui::style::Color;
230 use rstest::rstest;
231
232 #[test]
233 fn test_input_box() {
234 let mut input_box = InputBox {
235 text: String::new(),
236 cursor_position: 0,
237 };
238
239 input_box.enter_char('a');
240 assert_eq!(input_box.text, "a");
241 assert_eq!(input_box.cursor_position, 1);
242
243 input_box.enter_char('b');
244 assert_eq!(input_box.text, "ab");
245 assert_eq!(input_box.cursor_position, 2);
246
247 input_box.enter_char('c');
248 assert_eq!(input_box.text, "abc");
249 assert_eq!(input_box.cursor_position, 3);
250
251 input_box.move_cursor_left();
252 assert_eq!(input_box.cursor_position, 2);
253
254 input_box.delete_char();
255 assert_eq!(input_box.text, "ac");
256 assert_eq!(input_box.cursor_position, 1);
257
258 input_box.enter_char('d');
259 assert_eq!(input_box.text, "adc");
260 assert_eq!(input_box.cursor_position, 2);
261
262 input_box.move_cursor_right();
263 assert_eq!(input_box.cursor_position, 3);
264
265 input_box.reset();
266 assert_eq!(input_box.text, "");
267 assert_eq!(input_box.cursor_position, 0);
268
269 input_box.delete_char();
270 assert_eq!(input_box.text, "");
271 assert_eq!(input_box.cursor_position, 0);
272
273 input_box.delete_char();
274 assert_eq!(input_box.text, "");
275 assert_eq!(input_box.cursor_position, 0);
276 }
277
278 #[test]
279 fn test_entering_non_ascii_char() {
280 let mut input_box = InputBox {
281 text: String::new(),
282 cursor_position: 0,
283 };
284
285 input_box.enter_char('a');
286 assert_eq!(input_box.text, "a");
287 assert_eq!(input_box.cursor_position, 1);
288
289 input_box.enter_char('m');
290 assert_eq!(input_box.text, "am");
291 assert_eq!(input_box.cursor_position, 2);
292
293 input_box.enter_char('é');
294 assert_eq!(input_box.text, "amé");
295 assert_eq!(input_box.cursor_position, 3);
296
297 input_box.enter_char('l');
298 assert_eq!(input_box.text, "amél");
299 assert_eq!(input_box.cursor_position, 4);
300 }
301
302 #[test]
303 fn test_input_box_clamp_cursor() {
304 let input_box = InputBox {
305 text: String::new(),
306 cursor_position: 0,
307 };
308
309 assert_eq!(input_box.clamp_cursor(0), 0);
310 assert_eq!(input_box.clamp_cursor(1), 0);
311
312 let input_box = InputBox {
313 text: "abc".to_string(),
314 cursor_position: 3,
315 };
316
317 assert_eq!(input_box.clamp_cursor(3), 3);
318 assert_eq!(input_box.clamp_cursor(4), 3);
319 }
320
321 #[test]
322 fn test_input_box_is_empty() {
323 let input_box = InputBox {
324 text: String::new(),
325 cursor_position: 0,
326 };
327
328 assert!(input_box.is_empty());
329
330 let input_box = InputBox {
331 text: "abc".to_string(),
332 cursor_position: 3,
333 };
334
335 assert!(!input_box.is_empty());
336 }
337
338 #[test]
339 fn test_input_box_text() {
340 let input_box = InputBox {
341 text: "abc".to_string(),
342 cursor_position: 3,
343 };
344
345 assert_eq!(input_box.text(), "abc");
346 }
347
348 #[rstest]
349 fn test_input_box_render(
350 #[values(10, 20)] width: u16,
351 #[values(1, 2, 3, 4, 5, 6)] height: u16,
352 #[values(true, false)] show_cursor: bool,
353 ) -> Result<()> {
354 use ratatui::{buffer::Buffer, text::Line};
355
356 let (mut terminal, _) = setup_test_terminal(width, height);
357 let action_tx = tokio::sync::mpsc::unbounded_channel().0;
358 let mut input_box = InputBox::new(&AppState::default(), action_tx);
359 input_box.set_text("Hello, World!");
360 let props = RenderProps {
361 border: Block::bordered(),
362 area: Rect::new(0, 0, width, height),
363 text_color: Color::Reset,
364 show_cursor,
365 };
366 let buffer = terminal
367 .draw(|frame| input_box.render(frame, props))?
368 .buffer
369 .clone();
370
371 let line_top = Line::raw(String::from("┌") + &"─".repeat((width - 2).into()) + "┐");
372 let line_text = if width > 15 {
373 Line::raw(String::from("│Hello, World!") + &" ".repeat((width - 15).into()) + "│")
374 } else {
375 Line::raw(
376 "│Hello, World!"
377 .chars()
378 .take((width - 1).into())
379 .collect::<String>()
380 + "│",
381 )
382 };
383 let line_empty = Line::raw(String::from("│") + &" ".repeat((width - 2).into()) + "│");
384 let line_bottom = Line::raw(String::from("└") + &"─".repeat((width - 2).into()) + "┘");
385
386 let expected = Buffer::with_lines(match height {
387 0 => unreachable!(),
388 1 => vec![line_top].into_iter(),
389 2 => vec![line_top, line_bottom].into_iter(),
390 3 => vec![line_top, line_text, line_bottom].into_iter(),
391 other => vec![line_top, line_text]
392 .into_iter()
393 .chain(
394 std::iter::repeat_n(line_empty, (other - 3).into())
395 .chain(std::iter::once(line_bottom)),
396 )
397 .collect::<Vec<_>>()
398 .into_iter(),
399 });
400
401 assert_eq!(buffer, expected);
402
403 Ok(())
404 }
405}