tui_realm_stdlib/components/
select.rs

1//! ## Select
2//!
3//! `Select` represents a select field, like in HTML. The size for the component must be 3 (border + selected) + the quantity of rows
4//! you want to display other options when opened (at least 3)
5
6use tuirealm::command::{Cmd, CmdResult, Direction};
7use tuirealm::props::{
8    Alignment, AttrValue, Attribute, BorderSides, Borders, Color, PropPayload, PropValue, Props,
9    Style, TextModifiers,
10};
11use tuirealm::ratatui::text::Line as Spans;
12use tuirealm::ratatui::{
13    layout::{Constraint, Direction as LayoutDirection, Layout, Rect},
14    widgets::{List, ListItem, ListState, Paragraph},
15};
16use tuirealm::{Frame, MockComponent, State, StateValue};
17
18// -- states
19
20/// ## SelectStates
21///
22/// Component states
23#[derive(Default)]
24pub struct SelectStates {
25    /// Available choices
26    pub choices: Vec<String>,
27    /// Currently selected choice
28    pub selected: usize,
29    /// Choice selected before opening the tab
30    pub previously_selected: usize,
31    pub tab_open: bool,
32}
33
34impl SelectStates {
35    /// ### next_choice
36    ///
37    /// Move choice index to next choice
38    pub fn next_choice(&mut self, rewind: bool) {
39        if self.tab_open {
40            if rewind && self.selected + 1 >= self.choices.len() {
41                self.selected = 0;
42            } else if self.selected + 1 < self.choices.len() {
43                self.selected += 1;
44            }
45        }
46    }
47
48    /// ### prev_choice
49    ///
50    /// Move choice index to previous choice
51    pub fn prev_choice(&mut self, rewind: bool) {
52        if self.tab_open {
53            if rewind && self.selected == 0 && !self.choices.is_empty() {
54                self.selected = self.choices.len() - 1;
55            } else if self.selected > 0 {
56                self.selected -= 1;
57            }
58        }
59    }
60
61    /// ### set_choices
62    ///
63    /// Set SelectStates choices from a vector of str
64    /// In addition resets current selection and keep index if possible or set it to the first value
65    /// available
66    pub fn set_choices(&mut self, choices: impl Into<Vec<String>>) {
67        self.choices = choices.into();
68        // Keep index if possible
69        if self.selected >= self.choices.len() {
70            self.selected = match self.choices.len() {
71                0 => 0,
72                l => l - 1,
73            };
74        }
75    }
76
77    pub fn select(&mut self, i: usize) {
78        if i < self.choices.len() {
79            self.selected = i;
80        }
81    }
82
83    /// ### close_tab
84    ///
85    /// Close tab
86    pub fn close_tab(&mut self) {
87        self.tab_open = false;
88    }
89
90    /// ### open_tab
91    ///
92    /// Open tab
93    pub fn open_tab(&mut self) {
94        self.previously_selected = self.selected;
95        self.tab_open = true;
96    }
97
98    /// Cancel tab open
99    pub fn cancel_tab(&mut self) {
100        self.close_tab();
101        self.selected = self.previously_selected;
102    }
103
104    /// ### is_tab_open
105    ///
106    /// Returns whether the tab is open
107    #[must_use]
108    pub fn is_tab_open(&self) -> bool {
109        self.tab_open
110    }
111}
112
113// -- component
114
115#[derive(Default)]
116#[must_use]
117pub struct Select {
118    props: Props,
119    pub states: SelectStates,
120}
121
122impl Select {
123    pub fn foreground(mut self, fg: Color) -> Self {
124        self.attr(Attribute::Foreground, AttrValue::Color(fg));
125        self
126    }
127
128    pub fn background(mut self, bg: Color) -> Self {
129        self.attr(Attribute::Background, AttrValue::Color(bg));
130        self
131    }
132
133    pub fn borders(mut self, b: Borders) -> Self {
134        self.attr(Attribute::Borders, AttrValue::Borders(b));
135        self
136    }
137
138    pub fn title<S: Into<String>>(mut self, t: S, a: Alignment) -> Self {
139        self.attr(Attribute::Title, AttrValue::Title((t.into(), a)));
140        self
141    }
142
143    pub fn highlighted_str<S: Into<String>>(mut self, s: S) -> Self {
144        self.attr(Attribute::HighlightedStr, AttrValue::String(s.into()));
145        self
146    }
147
148    pub fn highlighted_color(mut self, c: Color) -> Self {
149        self.attr(Attribute::HighlightedColor, AttrValue::Color(c));
150        self
151    }
152
153    pub fn inactive(mut self, s: Style) -> Self {
154        self.attr(Attribute::FocusStyle, AttrValue::Style(s));
155        self
156    }
157
158    pub fn rewind(mut self, r: bool) -> Self {
159        self.attr(Attribute::Rewind, AttrValue::Flag(r));
160        self
161    }
162
163    pub fn choices<S: Into<String>>(mut self, choices: impl IntoIterator<Item = S>) -> Self {
164        self.attr(
165            Attribute::Content,
166            AttrValue::Payload(PropPayload::Vec(
167                choices
168                    .into_iter()
169                    .map(|v| PropValue::Str(v.into()))
170                    .collect(),
171            )),
172        );
173        self
174    }
175
176    pub fn value(mut self, i: usize) -> Self {
177        // Set state
178        self.attr(
179            Attribute::Value,
180            AttrValue::Payload(PropPayload::One(PropValue::Usize(i))),
181        );
182        self
183    }
184
185    /// ### render_open_tab
186    ///
187    /// Render component when tab is open
188    fn render_open_tab(&mut self, render: &mut Frame, area: Rect) {
189        // Make choices
190        let choices: Vec<ListItem> = self
191            .states
192            .choices
193            .iter()
194            .map(|x| ListItem::new(Spans::from(x.as_str())))
195            .collect();
196        let foreground = self
197            .props
198            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
199            .unwrap_color();
200        let background = self
201            .props
202            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
203            .unwrap_color();
204        let hg: Color = self
205            .props
206            .get_or(Attribute::HighlightedColor, AttrValue::Color(foreground))
207            .unwrap_color();
208        // Prepare layout
209        let chunks = Layout::default()
210            .direction(LayoutDirection::Vertical)
211            .margin(0)
212            .constraints([Constraint::Length(2), Constraint::Min(1)])
213            .split(area);
214        // Render like "closed" tab in chunk 0
215        let selected_text: String = match self.states.choices.get(self.states.selected) {
216            None => String::default(),
217            Some(s) => s.clone(),
218        };
219        let focus = self
220            .props
221            .get_or(Attribute::Focus, AttrValue::Flag(false))
222            .unwrap_flag();
223        let inactive_style = self
224            .props
225            .get(Attribute::FocusStyle)
226            .map(|x| x.unwrap_style());
227
228        let normal_style = Style::default().bg(background).fg(foreground);
229
230        let borders = self
231            .props
232            .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
233            .unwrap_borders();
234        let title = self
235            .props
236            .get_ref(Attribute::Title)
237            .and_then(|x| x.as_title());
238        let block_a = crate::utils::get_block(borders, title, focus, inactive_style)
239            .borders(BorderSides::LEFT | BorderSides::TOP | BorderSides::RIGHT);
240        let block_b = crate::utils::get_block::<&str>(borders, None, focus, inactive_style)
241            .borders(BorderSides::LEFT | BorderSides::BOTTOM | BorderSides::RIGHT);
242
243        let p: Paragraph = Paragraph::new(selected_text)
244            .style(normal_style)
245            .block(block_a);
246        render.render_widget(p, chunks[0]);
247
248        // Render the list of elements in chunks [1]
249        // Make list
250        let mut list = List::new(choices)
251            .block(block_b)
252            .direction(tuirealm::ratatui::widgets::ListDirection::TopToBottom)
253            .style(normal_style)
254            .highlight_style(
255                Style::default()
256                    .fg(hg)
257                    .add_modifier(TextModifiers::REVERSED),
258            );
259        // Highlighted symbol
260        let hg_str = self
261            .props
262            .get_ref(Attribute::HighlightedStr)
263            .and_then(|x| x.as_string());
264        if let Some(hg_str) = hg_str {
265            list = list.highlight_symbol(hg_str);
266        }
267        let mut state: ListState = ListState::default();
268        state.select(Some(self.states.selected));
269        render.render_stateful_widget(list, chunks[1], &mut state);
270    }
271
272    /// ### render_closed_tab
273    ///
274    /// Render component when tab is closed
275    fn render_closed_tab(&self, render: &mut Frame, area: Rect) {
276        let foreground = self
277            .props
278            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
279            .unwrap_color();
280        let background = self
281            .props
282            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
283            .unwrap_color();
284        let inactive_style = self
285            .props
286            .get(Attribute::FocusStyle)
287            .map(|x| x.unwrap_style());
288        let focus = self
289            .props
290            .get_or(Attribute::Focus, AttrValue::Flag(false))
291            .unwrap_flag();
292
293        let normal_style = Style::default().bg(background).fg(foreground);
294
295        let borders = self
296            .props
297            .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
298            .unwrap_borders();
299        let title = self
300            .props
301            .get_ref(Attribute::Title)
302            .and_then(|x| x.as_title());
303        let block = crate::utils::get_block(borders, title, focus, inactive_style);
304
305        let selected_text: String = match self.states.choices.get(self.states.selected) {
306            None => String::default(),
307            Some(s) => s.clone(),
308        };
309        let p: Paragraph = Paragraph::new(selected_text)
310            .style(normal_style)
311            .block(block);
312        render.render_widget(p, area);
313    }
314
315    fn rewindable(&self) -> bool {
316        self.props
317            .get_or(Attribute::Rewind, AttrValue::Flag(false))
318            .unwrap_flag()
319    }
320}
321
322impl MockComponent for Select {
323    fn view(&mut self, render: &mut Frame, area: Rect) {
324        if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) {
325            if self.states.is_tab_open() {
326                self.render_open_tab(render, area);
327            } else {
328                self.render_closed_tab(render, area);
329            }
330        }
331    }
332
333    fn query(&self, attr: Attribute) -> Option<AttrValue> {
334        self.props.get(attr)
335    }
336
337    fn attr(&mut self, attr: Attribute, value: AttrValue) {
338        match attr {
339            Attribute::Content => {
340                // Reset choices
341                let choices: Vec<String> = value
342                    .unwrap_payload()
343                    .unwrap_vec()
344                    .iter()
345                    .map(|x| x.clone().unwrap_str())
346                    .collect();
347                self.states.set_choices(choices);
348            }
349            Attribute::Value => {
350                self.states
351                    .select(value.unwrap_payload().unwrap_one().unwrap_usize());
352            }
353            Attribute::Focus if self.states.is_tab_open() => {
354                if let AttrValue::Flag(false) = value {
355                    self.states.cancel_tab();
356                }
357                self.props.set(attr, value);
358            }
359            attr => {
360                self.props.set(attr, value);
361            }
362        }
363    }
364
365    fn state(&self) -> State {
366        if self.states.is_tab_open() {
367            State::None
368        } else {
369            State::One(StateValue::Usize(self.states.selected))
370        }
371    }
372
373    fn perform(&mut self, cmd: Cmd) -> CmdResult {
374        match cmd {
375            Cmd::Move(Direction::Down) => {
376                // Increment choice
377                self.states.next_choice(self.rewindable());
378                // Return CmdResult On Change or None if tab is closed
379                if self.states.is_tab_open() {
380                    CmdResult::Changed(State::One(StateValue::Usize(self.states.selected)))
381                } else {
382                    CmdResult::None
383                }
384            }
385            Cmd::Move(Direction::Up) => {
386                // Increment choice
387                self.states.prev_choice(self.rewindable());
388                // Return CmdResult On Change or None if tab is closed
389                if self.states.is_tab_open() {
390                    CmdResult::Changed(State::One(StateValue::Usize(self.states.selected)))
391                } else {
392                    CmdResult::None
393                }
394            }
395            Cmd::Cancel => {
396                self.states.cancel_tab();
397                CmdResult::Changed(self.state())
398            }
399            Cmd::Submit => {
400                // Open or close tab
401                if self.states.is_tab_open() {
402                    self.states.close_tab();
403                    CmdResult::Submit(self.state())
404                } else {
405                    self.states.open_tab();
406                    CmdResult::None
407                }
408            }
409            _ => CmdResult::None,
410        }
411    }
412}
413
414#[cfg(test)]
415mod test {
416
417    use super::*;
418
419    use pretty_assertions::assert_eq;
420
421    use tuirealm::props::{PropPayload, PropValue};
422
423    #[test]
424    fn test_components_select_states() {
425        let mut states: SelectStates = SelectStates::default();
426        assert_eq!(states.selected, 0);
427        assert_eq!(states.choices.len(), 0);
428        assert_eq!(states.tab_open, false);
429        let choices: &[String] = &[
430            "lemon".to_string(),
431            "strawberry".to_string(),
432            "vanilla".to_string(),
433            "chocolate".to_string(),
434        ];
435        states.set_choices(choices);
436        assert_eq!(states.selected, 0);
437        assert_eq!(states.choices.len(), 4);
438        // Move
439        states.prev_choice(false);
440        assert_eq!(states.selected, 0);
441        states.next_choice(false);
442        // Tab is closed!!!
443        assert_eq!(states.selected, 0);
444        states.open_tab();
445        assert_eq!(states.is_tab_open(), true);
446        // Now we can move
447        states.next_choice(false);
448        assert_eq!(states.selected, 1);
449        states.next_choice(false);
450        assert_eq!(states.selected, 2);
451        // Forward overflow
452        states.next_choice(false);
453        states.next_choice(false);
454        assert_eq!(states.selected, 3);
455        states.prev_choice(false);
456        assert_eq!(states.selected, 2);
457        // Close tab
458        states.close_tab();
459        assert_eq!(states.is_tab_open(), false);
460        states.prev_choice(false);
461        assert_eq!(states.selected, 2);
462        // Update
463        let choices: &[String] = &["lemon".to_string(), "strawberry".to_string()];
464        states.set_choices(choices);
465        assert_eq!(states.selected, 1); // Move to first index available
466        assert_eq!(states.choices.len(), 2);
467        let choices = vec![];
468        states.set_choices(choices);
469        assert_eq!(states.selected, 0); // Move to first index available
470        assert_eq!(states.choices.len(), 0);
471        // Rewind
472        let choices: &[String] = &[
473            "lemon".to_string(),
474            "strawberry".to_string(),
475            "vanilla".to_string(),
476            "chocolate".to_string(),
477        ];
478        states.set_choices(choices);
479        states.open_tab();
480        assert_eq!(states.selected, 0);
481        states.prev_choice(true);
482        assert_eq!(states.selected, 3);
483        states.next_choice(true);
484        assert_eq!(states.selected, 0);
485        states.next_choice(true);
486        assert_eq!(states.selected, 1);
487        states.prev_choice(true);
488        assert_eq!(states.selected, 0);
489        // Cancel tab
490        states.close_tab();
491        states.select(2);
492        states.open_tab();
493        states.prev_choice(true);
494        states.prev_choice(true);
495        assert_eq!(states.selected, 0);
496        states.cancel_tab();
497        assert_eq!(states.selected, 2);
498        assert_eq!(states.is_tab_open(), false);
499    }
500
501    #[test]
502    fn test_components_select() {
503        // Make component
504        let mut component = Select::default()
505            .foreground(Color::Red)
506            .background(Color::Black)
507            .borders(Borders::default())
508            .highlighted_color(Color::Red)
509            .highlighted_str(">>")
510            .title("C'est oui ou bien c'est non?", Alignment::Center)
511            .choices(["Oui!", "Non", "Peut-ĂȘtre"])
512            .value(1)
513            .rewind(false);
514        assert_eq!(component.states.is_tab_open(), false);
515        component.states.open_tab();
516        assert_eq!(component.states.is_tab_open(), true);
517        component.states.close_tab();
518        assert_eq!(component.states.is_tab_open(), false);
519        // Update
520        component.attr(
521            Attribute::Value,
522            AttrValue::Payload(PropPayload::One(PropValue::Usize(2))),
523        );
524        // Get value
525        assert_eq!(component.state(), State::One(StateValue::Usize(2)));
526        // Open tab
527        component.states.open_tab();
528        // Events
529        // Move cursor
530        assert_eq!(
531            component.perform(Cmd::Move(Direction::Up)),
532            CmdResult::Changed(State::One(StateValue::Usize(1))),
533        );
534        assert_eq!(
535            component.perform(Cmd::Move(Direction::Up)),
536            CmdResult::Changed(State::One(StateValue::Usize(0))),
537        );
538        // Upper boundary
539        assert_eq!(
540            component.perform(Cmd::Move(Direction::Up)),
541            CmdResult::Changed(State::One(StateValue::Usize(0))),
542        );
543        // Move down
544        assert_eq!(
545            component.perform(Cmd::Move(Direction::Down)),
546            CmdResult::Changed(State::One(StateValue::Usize(1))),
547        );
548        assert_eq!(
549            component.perform(Cmd::Move(Direction::Down)),
550            CmdResult::Changed(State::One(StateValue::Usize(2))),
551        );
552        // Lower boundary
553        assert_eq!(
554            component.perform(Cmd::Move(Direction::Down)),
555            CmdResult::Changed(State::One(StateValue::Usize(2))),
556        );
557        // Press enter
558        assert_eq!(
559            component.perform(Cmd::Submit),
560            CmdResult::Submit(State::One(StateValue::Usize(2))),
561        );
562        // Tab should be closed
563        assert_eq!(component.states.is_tab_open(), false);
564        // Re open
565        assert_eq!(component.perform(Cmd::Submit), CmdResult::None);
566        assert_eq!(component.states.is_tab_open(), true);
567        // Move arrows
568        assert_eq!(
569            component.perform(Cmd::Submit),
570            CmdResult::Submit(State::One(StateValue::Usize(2))),
571        );
572        assert_eq!(component.states.is_tab_open(), false);
573        assert_eq!(
574            component.perform(Cmd::Move(Direction::Down)),
575            CmdResult::None
576        );
577        assert_eq!(component.perform(Cmd::Move(Direction::Up)), CmdResult::None);
578    }
579
580    #[test]
581    fn various_set_choice_types() {
582        // static array of strings
583        SelectStates::default().set_choices(&["hello".to_string()]);
584        // vector of strings
585        SelectStates::default().set_choices(vec!["hello".to_string()]);
586        // boxed array of strings
587        SelectStates::default().set_choices(vec!["hello".to_string()].into_boxed_slice());
588    }
589
590    #[test]
591    fn various_choice_types() {
592        // static array of static strings
593        let _ = Select::default().choices(["hello"]);
594        // static array of strings
595        let _ = Select::default().choices(["hello".to_string()]);
596        // vec of static strings
597        let _ = Select::default().choices(vec!["hello"]);
598        // vec of strings
599        let _ = Select::default().choices(vec!["hello".to_string()]);
600        // boxed array of static strings
601        let _ = Select::default().choices(vec!["hello"].into_boxed_slice());
602        // boxed array of strings
603        let _ = Select::default().choices(vec!["hello".to_string()].into_boxed_slice());
604    }
605}