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