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