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