Skip to main content

mecomp_tui/ui/widgets/
input_box.rs

1//! Implementation of a search bar
2
3use 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    /// Current value of the input box
23    text: String,
24    /// Position of cursor in the editor area.
25    cursor_position: usize,
26}
27
28impl InputBox {
29    #[must_use]
30    #[allow(clippy::missing_const_for_fn)] // TODO: make this const
31    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        // we need to convert the cursor position (which is in characters) to the byte index
62        // of the cursor position in the string
63        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    // Delete the character before the cursor (backspace)
76    fn delete_char(&mut self) {
77        if self.cursor_position == 0 {
78            return;
79        }
80
81        // Method "remove" is not used on the saved text for deleting the selected char.
82        // Reason: Using remove on String works on bytes instead of the chars.
83        // Using remove would require special care because of char boundaries.
84
85        let current_index = self.cursor_position;
86        let from_left_to_current_index = current_index - 1;
87
88        // Getting all characters before the selected character.
89        let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
90        // Getting all characters after selected character.
91        let after_char_to_delete = self.text.chars().skip(current_index);
92
93        // Put all characters together except the selected one.
94        // By leaving the selected one out, it is forgotten and therefore deleted.
95        self.text = before_char_to_delete.chain(after_char_to_delete).collect();
96        self.move_cursor_left();
97    }
98
99    // delete the character under the cursor (delete)
100    fn delete_next_char(&mut self) {
101        // same procedure as with `self.delete_char()`, but we don't need to
102        // descrement the cursor position
103        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            //
117            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    /// Handle mouse events
165    ///
166    /// moves the cursor to the clicked position
167    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            // NOTE: this assumes that the border is 1 character wide, which may not necessarily be true
179            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        // Cursor is hidden by default, so we need to make it visible if the input box is selected
209        if props.show_cursor {
210            // Make the cursor visible and ask ratatui to put it at the specified coordinates after
211            // rendering
212            #[allow(clippy::cast_possible_truncation)]
213            frame.set_cursor_position(
214                // Draw the cursor at the current position in the input field.
215                // This position is can be controlled via the left and right arrow key
216                (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}