tui_realm_stdlib/components/
input.rs

1//! ## Input
2//!
3//! `Input` represents a read-write input field. This component supports different input types, input length
4//! and handles input events related to cursor position, backspace, canc, ...
5
6use super::props::{INPUT_INVALID_STYLE, INPUT_PLACEHOLDER, INPUT_PLACEHOLDER_STYLE};
7use crate::utils::calc_utf8_cursor_position;
8use tuirealm::command::{Cmd, CmdResult, Direction, Position};
9use tuirealm::props::{
10    Alignment, AttrValue, Attribute, Borders, Color, InputType, Props, Style, TextModifiers,
11};
12use tuirealm::ratatui::{layout::Rect, widgets::Paragraph};
13use tuirealm::{Frame, MockComponent, State, StateValue};
14
15// -- states
16
17#[derive(Default)]
18pub struct InputStates {
19    pub input: Vec<char>, // Current input
20    pub cursor: usize,    // Input position
21}
22
23impl InputStates {
24    /// ### append
25    ///
26    /// Append, if possible according to input type, the character to the input vec
27    pub fn append(&mut self, ch: char, itype: &InputType, max_len: Option<usize>) {
28        // Check if max length has been reached
29        if self.input.len() < max_len.unwrap_or(usize::MAX) {
30            // Check whether can push
31            if itype.char_valid(self.input.iter().collect::<String>().as_str(), ch) {
32                self.input.insert(self.cursor, ch);
33                self.incr_cursor();
34            }
35        }
36    }
37
38    /// ### backspace
39    ///
40    /// Delete element at cursor -1; then decrement cursor by 1
41    pub fn backspace(&mut self) {
42        if self.cursor > 0 && !self.input.is_empty() {
43            self.input.remove(self.cursor - 1);
44            // Decrement cursor
45            self.cursor -= 1;
46        }
47    }
48
49    /// ### delete
50    ///
51    /// Delete element at cursor
52    pub fn delete(&mut self) {
53        if self.cursor < self.input.len() {
54            self.input.remove(self.cursor);
55        }
56    }
57
58    /// ### incr_cursor
59    ///
60    /// Increment cursor value by one if possible
61    pub fn incr_cursor(&mut self) {
62        if self.cursor < self.input.len() {
63            self.cursor += 1;
64        }
65    }
66
67    /// ### cursoro_at_begin
68    ///
69    /// Place cursor at the begin of the input
70    pub fn cursor_at_begin(&mut self) {
71        self.cursor = 0;
72    }
73
74    /// ### cursor_at_end
75    ///
76    /// Place cursor at the end of the input
77    pub fn cursor_at_end(&mut self) {
78        self.cursor = self.input.len();
79    }
80
81    /// ### decr_cursor
82    ///
83    /// Decrement cursor value by one if possible
84    pub fn decr_cursor(&mut self) {
85        if self.cursor > 0 {
86            self.cursor -= 1;
87        }
88    }
89
90    /// ### render_value
91    ///
92    /// Get value as string to render
93    #[must_use]
94    pub fn render_value(&self, itype: InputType) -> String {
95        self.render_value_chars(itype).iter().collect::<String>()
96    }
97
98    /// ### render_value_chars
99    ///
100    /// Render value as a vec of chars
101    #[must_use]
102    pub fn render_value_chars(&self, itype: InputType) -> Vec<char> {
103        match itype {
104            InputType::Password(ch) | InputType::CustomPassword(ch, _, _) => {
105                (0..self.input.len()).map(|_| ch).collect()
106            }
107            _ => self.input.clone(),
108        }
109    }
110
111    /// ### get_value
112    ///
113    /// Get value as string
114    #[must_use]
115    pub fn get_value(&self) -> String {
116        self.input.iter().collect()
117    }
118}
119
120// -- Component
121
122/// ## Input
123///
124/// Input list component
125#[derive(Default)]
126#[must_use]
127pub struct Input {
128    props: Props,
129    pub states: InputStates,
130}
131
132impl Input {
133    pub fn foreground(mut self, fg: Color) -> Self {
134        self.attr(Attribute::Foreground, AttrValue::Color(fg));
135        self
136    }
137
138    pub fn background(mut self, bg: Color) -> Self {
139        self.attr(Attribute::Background, AttrValue::Color(bg));
140        self
141    }
142
143    pub fn inactive(mut self, s: Style) -> Self {
144        self.attr(Attribute::FocusStyle, AttrValue::Style(s));
145        self
146    }
147
148    pub fn borders(mut self, b: Borders) -> Self {
149        self.attr(Attribute::Borders, AttrValue::Borders(b));
150        self
151    }
152
153    pub fn title<S: Into<String>>(mut self, t: S, a: Alignment) -> Self {
154        self.attr(Attribute::Title, AttrValue::Title((t.into(), a)));
155        self
156    }
157
158    pub fn input_type(mut self, itype: InputType) -> Self {
159        self.attr(Attribute::InputType, AttrValue::InputType(itype));
160        self
161    }
162
163    pub fn input_len(mut self, ilen: usize) -> Self {
164        self.attr(Attribute::InputLength, AttrValue::Length(ilen));
165        self
166    }
167
168    pub fn value<S: Into<String>>(mut self, s: S) -> Self {
169        self.attr(Attribute::Value, AttrValue::String(s.into()));
170        self
171    }
172
173    pub fn invalid_style(mut self, s: Style) -> Self {
174        self.attr(Attribute::Custom(INPUT_INVALID_STYLE), AttrValue::Style(s));
175        self
176    }
177
178    pub fn placeholder<S: Into<String>>(mut self, placeholder: S, style: Style) -> Self {
179        self.attr(
180            Attribute::Custom(INPUT_PLACEHOLDER),
181            AttrValue::String(placeholder.into()),
182        );
183        self.attr(
184            Attribute::Custom(INPUT_PLACEHOLDER_STYLE),
185            AttrValue::Style(style),
186        );
187        self
188    }
189
190    fn get_input_len(&self) -> Option<usize> {
191        self.props
192            .get(Attribute::InputLength)
193            .map(|x| x.unwrap_length())
194    }
195
196    fn get_input_type(&self) -> InputType {
197        self.props
198            .get_or(Attribute::InputType, AttrValue::InputType(InputType::Text))
199            .unwrap_input_type()
200    }
201
202    /// ### is_valid
203    ///
204    /// Checks whether current input is valid
205    fn is_valid(&self) -> bool {
206        let value = self.states.get_value();
207        self.get_input_type().validate(value.as_str())
208    }
209}
210
211impl MockComponent for Input {
212    fn view(&mut self, render: &mut Frame, area: Rect) {
213        if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) {
214            let mut foreground = self
215                .props
216                .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
217                .unwrap_color();
218            let mut background = self
219                .props
220                .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
221                .unwrap_color();
222            let modifiers = self
223                .props
224                .get_or(
225                    Attribute::TextProps,
226                    AttrValue::TextModifiers(TextModifiers::empty()),
227                )
228                .unwrap_text_modifiers();
229            let title = crate::utils::get_title_or_center(&self.props);
230            let borders = self
231                .props
232                .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
233                .unwrap_borders();
234            let focus = self
235                .props
236                .get_or(Attribute::Focus, AttrValue::Flag(false))
237                .unwrap_flag();
238            let inactive_style = self
239                .props
240                .get(Attribute::FocusStyle)
241                .map(|x| x.unwrap_style());
242            let itype = self.get_input_type();
243            let mut block = crate::utils::get_block(borders, Some(&title), focus, inactive_style);
244            // Apply invalid style
245            if focus && !self.is_valid() {
246                if let Some(style) = self
247                    .props
248                    .get(Attribute::Custom(INPUT_INVALID_STYLE))
249                    .map(|x| x.unwrap_style())
250                {
251                    let borders = self
252                        .props
253                        .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
254                        .unwrap_borders()
255                        .color(style.fg.unwrap_or(Color::Reset));
256                    block = crate::utils::get_block(borders, Some(&title), focus, None);
257                    foreground = style.fg.unwrap_or(Color::Reset);
258                    background = style.bg.unwrap_or(Color::Reset);
259                }
260            }
261            let text_to_display = self.states.render_value(self.get_input_type());
262            let show_placeholder = text_to_display.is_empty();
263            // Choose whether to show placeholder; if placeholder is unset, show nothing
264            let text_to_display = if show_placeholder {
265                self.props
266                    .get_or(
267                        Attribute::Custom(INPUT_PLACEHOLDER),
268                        AttrValue::String(String::new()),
269                    )
270                    .unwrap_string()
271            } else {
272                text_to_display
273            };
274            // Choose paragraph style based on whether is valid or not and if has focus and if should show placeholder
275            let paragraph_style = if focus {
276                Style::default()
277                    .fg(foreground)
278                    .bg(background)
279                    .add_modifier(modifiers)
280            } else {
281                inactive_style.unwrap_or_default()
282            };
283            let paragraph_style = if show_placeholder {
284                self.props
285                    .get_or(
286                        Attribute::Custom(INPUT_PLACEHOLDER_STYLE),
287                        AttrValue::Style(paragraph_style),
288                    )
289                    .unwrap_style()
290            } else {
291                paragraph_style
292            };
293            // Create widget
294            let block_inner_area = block.inner(area);
295            let p: Paragraph = Paragraph::new(text_to_display)
296                .style(paragraph_style)
297                .block(block);
298            render.render_widget(p, area);
299            // Set cursor, if focus
300            if focus {
301                let x: u16 = block_inner_area.x
302                    + calc_utf8_cursor_position(
303                        &self.states.render_value_chars(itype)[0..self.states.cursor],
304                    );
305                render
306                    .set_cursor_position(tuirealm::ratatui::prelude::Position { x, y: area.y + 1 });
307            }
308        }
309    }
310
311    fn query(&self, attr: Attribute) -> Option<AttrValue> {
312        self.props.get(attr)
313    }
314
315    fn attr(&mut self, attr: Attribute, value: AttrValue) {
316        let sanitize_input = matches!(
317            attr,
318            Attribute::InputLength | Attribute::InputType | Attribute::Value
319        );
320        // Check if new input
321        let new_input = match attr {
322            Attribute::Value => Some(value.clone().unwrap_string()),
323            _ => None,
324        };
325        self.props.set(attr, value);
326        if sanitize_input {
327            let input = match new_input {
328                None => self.states.input.clone(),
329                Some(v) => v.chars().collect(),
330            };
331            self.states.input = Vec::new();
332            self.states.cursor = 0;
333            let itype = self.get_input_type();
334            let max_len = self.get_input_len();
335            for ch in input {
336                self.states.append(ch, &itype, max_len);
337            }
338        }
339    }
340
341    fn state(&self) -> State {
342        // Validate input
343        if self.is_valid() {
344            State::One(StateValue::String(self.states.get_value()))
345        } else {
346            State::None
347        }
348    }
349
350    fn perform(&mut self, cmd: Cmd) -> CmdResult {
351        match cmd {
352            Cmd::Delete => {
353                // Backspace and None
354                let prev_input = self.states.input.clone();
355                self.states.backspace();
356                if prev_input == self.states.input {
357                    CmdResult::None
358                } else {
359                    CmdResult::Changed(self.state())
360                }
361            }
362            Cmd::Cancel => {
363                // Delete and None
364                let prev_input = self.states.input.clone();
365                self.states.delete();
366                if prev_input == self.states.input {
367                    CmdResult::None
368                } else {
369                    CmdResult::Changed(self.state())
370                }
371            }
372            Cmd::Submit => CmdResult::Submit(self.state()),
373            Cmd::Move(Direction::Left) => {
374                self.states.decr_cursor();
375                CmdResult::None
376            }
377            Cmd::Move(Direction::Right) => {
378                self.states.incr_cursor();
379                CmdResult::None
380            }
381            Cmd::GoTo(Position::Begin) => {
382                self.states.cursor_at_begin();
383                CmdResult::None
384            }
385            Cmd::GoTo(Position::End) => {
386                self.states.cursor_at_end();
387                CmdResult::None
388            }
389            Cmd::Type(ch) => {
390                // Push char to input
391                let prev_input = self.states.input.clone();
392                self.states
393                    .append(ch, &self.get_input_type(), self.get_input_len());
394                // Message on change
395                if prev_input == self.states.input {
396                    CmdResult::None
397                } else {
398                    CmdResult::Changed(self.state())
399                }
400            }
401            _ => CmdResult::None,
402        }
403    }
404}
405
406#[cfg(test)]
407mod tests {
408
409    use super::*;
410
411    use pretty_assertions::assert_eq;
412
413    #[test]
414    fn test_components_input_states() {
415        let mut states: InputStates = InputStates::default();
416        states.append('a', &InputType::Text, Some(3));
417        assert_eq!(states.input, vec!['a']);
418        states.append('b', &InputType::Text, Some(3));
419        assert_eq!(states.input, vec!['a', 'b']);
420        states.append('c', &InputType::Text, Some(3));
421        assert_eq!(states.input, vec!['a', 'b', 'c']);
422        // Reached length
423        states.append('d', &InputType::Text, Some(3));
424        assert_eq!(states.input, vec!['a', 'b', 'c']);
425        // Push char to numbers
426        states.append('d', &InputType::Number, None);
427        assert_eq!(states.input, vec!['a', 'b', 'c']);
428        // move cursor
429        // decr cursor
430        states.decr_cursor();
431        assert_eq!(states.cursor, 2);
432        states.cursor = 1;
433        states.decr_cursor();
434        assert_eq!(states.cursor, 0);
435        states.decr_cursor();
436        assert_eq!(states.cursor, 0);
437        // Incr
438        states.incr_cursor();
439        assert_eq!(states.cursor, 1);
440        states.incr_cursor();
441        assert_eq!(states.cursor, 2);
442        states.incr_cursor();
443        assert_eq!(states.cursor, 3);
444        // Render value
445        assert_eq!(states.render_value(InputType::Text).as_str(), "abc");
446        assert_eq!(
447            states.render_value(InputType::Password('*')).as_str(),
448            "***"
449        );
450    }
451
452    #[test]
453    fn test_components_input_text() {
454        // Instantiate Input with value
455        let mut component: Input = Input::default()
456            .background(Color::Yellow)
457            .borders(Borders::default())
458            .foreground(Color::Cyan)
459            .inactive(Style::default())
460            .input_len(5)
461            .input_type(InputType::Text)
462            .title("pippo", Alignment::Center)
463            .value("home");
464        // Verify initial state
465        assert_eq!(component.states.cursor, 4);
466        assert_eq!(component.states.input.len(), 4);
467        // Get value
468        assert_eq!(
469            component.state(),
470            State::One(StateValue::String(String::from("home")))
471        );
472        // Character
473        assert_eq!(
474            component.perform(Cmd::Type('/')),
475            CmdResult::Changed(State::One(StateValue::String(String::from("home/"))))
476        );
477        assert_eq!(
478            component.state(),
479            State::One(StateValue::String(String::from("home/")))
480        );
481        assert_eq!(component.states.cursor, 5);
482        // Verify max length (shouldn't push any character)
483        assert_eq!(component.perform(Cmd::Type('a')), CmdResult::None);
484        assert_eq!(
485            component.state(),
486            State::One(StateValue::String(String::from("home/")))
487        );
488        assert_eq!(component.states.cursor, 5);
489        // Submit
490        assert_eq!(
491            component.perform(Cmd::Submit),
492            CmdResult::Submit(State::One(StateValue::String(String::from("home/"))))
493        );
494        // Backspace
495        assert_eq!(
496            component.perform(Cmd::Delete),
497            CmdResult::Changed(State::One(StateValue::String(String::from("home"))))
498        );
499        assert_eq!(
500            component.state(),
501            State::One(StateValue::String(String::from("home")))
502        );
503        assert_eq!(component.states.cursor, 4);
504        // Check backspace at 0
505        component.states.input = vec!['h'];
506        component.states.cursor = 1;
507        assert_eq!(
508            component.perform(Cmd::Delete),
509            CmdResult::Changed(State::One(StateValue::String(String::new())))
510        );
511        assert_eq!(
512            component.state(),
513            State::One(StateValue::String(String::new()))
514        );
515        assert_eq!(component.states.cursor, 0);
516        // Another one...
517        assert_eq!(component.perform(Cmd::Delete), CmdResult::None);
518        assert_eq!(
519            component.state(),
520            State::One(StateValue::String(String::new()))
521        );
522        assert_eq!(component.states.cursor, 0);
523        // See del behaviour here
524        assert_eq!(component.perform(Cmd::Cancel), CmdResult::None);
525        assert_eq!(
526            component.state(),
527            State::One(StateValue::String(String::new()))
528        );
529        assert_eq!(component.states.cursor, 0);
530        // Check del behaviour
531        component.states.input = vec!['h', 'e'];
532        component.states.cursor = 1;
533        assert_eq!(
534            component.perform(Cmd::Cancel),
535            CmdResult::Changed(State::One(StateValue::String(String::from("h"))))
536        );
537        assert_eq!(
538            component.state(),
539            State::One(StateValue::String(String::from("h")))
540        );
541        assert_eq!(component.states.cursor, 1);
542        // Another one (should do nothing)
543        assert_eq!(component.perform(Cmd::Cancel), CmdResult::None);
544        assert_eq!(
545            component.state(),
546            State::One(StateValue::String(String::from("h")))
547        );
548        assert_eq!(component.states.cursor, 1);
549        // Move cursor right
550        component.states.input = vec!['h', 'e', 'l', 'l', 'o'];
551        // Update length to 16
552        component.attr(Attribute::InputLength, AttrValue::Length(16));
553        component.states.cursor = 1;
554        assert_eq!(
555            component.perform(Cmd::Move(Direction::Right)), // between 'e' and 'l'
556            CmdResult::None
557        );
558        assert_eq!(component.states.cursor, 2);
559        // Put a character here
560        assert_eq!(
561            component.perform(Cmd::Type('a')),
562            CmdResult::Changed(State::One(StateValue::String(String::from("heallo"))))
563        );
564        assert_eq!(
565            component.state(),
566            State::One(StateValue::String(String::from("heallo")))
567        );
568        assert_eq!(component.states.cursor, 3);
569        // Move left
570        assert_eq!(
571            component.perform(Cmd::Move(Direction::Left)),
572            CmdResult::None
573        );
574        assert_eq!(component.states.cursor, 2);
575        // Go at the end
576        component.states.cursor = 6;
577        // Move right
578        assert_eq!(component.perform(Cmd::GoTo(Position::End)), CmdResult::None);
579        assert_eq!(component.states.cursor, 6);
580        // Move left
581        assert_eq!(
582            component.perform(Cmd::Move(Direction::Left)),
583            CmdResult::None
584        );
585        assert_eq!(component.states.cursor, 5);
586        // Go at the beginning
587        component.states.cursor = 0;
588        assert_eq!(
589            component.perform(Cmd::Move(Direction::Left)),
590            CmdResult::None
591        );
592        //assert_eq!(component.render().unwrap().cursor, 0); // Should stay
593        assert_eq!(component.states.cursor, 0);
594        // End - begin
595        assert_eq!(component.perform(Cmd::GoTo(Position::End)), CmdResult::None);
596        assert_eq!(component.states.cursor, 6);
597        assert_eq!(
598            component.perform(Cmd::GoTo(Position::Begin)),
599            CmdResult::None
600        );
601        assert_eq!(component.states.cursor, 0);
602        // Update value
603        component.attr(Attribute::Value, AttrValue::String("new-value".to_string()));
604        assert_eq!(
605            component.state(),
606            State::One(StateValue::String(String::from("new-value")))
607        );
608        // Invalidate input type
609        component.attr(
610            Attribute::InputType,
611            AttrValue::InputType(InputType::Number),
612        );
613        assert_eq!(component.state(), State::None);
614    }
615}