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 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    fn delete_char(&mut self) {
76        if self.cursor_position == 0 {
77            return;
78        }
79
80        // Method "remove" is not used on the saved text for deleting the selected char.
81        // Reason: Using remove on String works on bytes instead of the chars.
82        // Using remove would require special care because of char boundaries.
83
84        let current_index = self.cursor_position;
85        let from_left_to_current_index = current_index - 1;
86
87        // Getting all characters before the selected character.
88        let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
89        // Getting all characters after selected character.
90        let after_char_to_delete = self.text.chars().skip(current_index);
91
92        // Put all characters together except the selected one.
93        // By leaving the selected one out, it is forgotten and therefore deleted.
94        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            //
107            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    /// Handle mouse events
152    ///
153    /// moves the cursor to the clicked position
154    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            // NOTE: this assumes that the border is 1 character wide, which may not necessarily be true
166            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        // Cursor is hidden by default, so we need to make it visible if the input box is selected
196        if props.show_cursor {
197            // Make the cursor visible and ask ratatui to put it at the specified coordinates after
198            // rendering
199            #[allow(clippy::cast_possible_truncation)]
200            frame.set_cursor_position(
201                // Draw the cursor at the current position in the input field.
202                // This position is can be controlled via the left and right arrow key
203                (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}