mecomp_tui/ui/widgets/
input_box.rs

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