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::{Block, 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: &[String]) {
67        self.choices = choices.to_vec();
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    pub fn is_tab_open(&self) -> bool {
108        self.tab_open
109    }
110}
111
112// -- component
113
114#[derive(Default)]
115pub struct Select {
116    props: Props,
117    pub states: SelectStates,
118    hg_str: Option<String>, // CRAP CRAP CRAP
119}
120
121impl Select {
122    pub fn foreground(mut self, fg: Color) -> Self {
123        self.attr(Attribute::Foreground, AttrValue::Color(fg));
124        self
125    }
126
127    pub fn background(mut self, bg: Color) -> Self {
128        self.attr(Attribute::Background, AttrValue::Color(bg));
129        self
130    }
131
132    pub fn borders(mut self, b: Borders) -> Self {
133        self.attr(Attribute::Borders, AttrValue::Borders(b));
134        self
135    }
136
137    pub fn title<S: Into<String>>(mut self, t: S, a: Alignment) -> Self {
138        self.attr(Attribute::Title, AttrValue::Title((t.into(), a)));
139        self
140    }
141
142    pub fn highlighted_str<S: Into<String>>(mut self, s: S) -> Self {
143        self.attr(Attribute::HighlightedStr, AttrValue::String(s.into()));
144        self
145    }
146
147    pub fn highlighted_color(mut self, c: Color) -> Self {
148        self.attr(Attribute::HighlightedColor, AttrValue::Color(c));
149        self
150    }
151
152    pub fn inactive(mut self, s: Style) -> Self {
153        self.attr(Attribute::FocusStyle, AttrValue::Style(s));
154        self
155    }
156
157    pub fn rewind(mut self, r: bool) -> Self {
158        self.attr(Attribute::Rewind, AttrValue::Flag(r));
159        self
160    }
161
162    pub fn choices<S: AsRef<str>>(mut self, choices: &[S]) -> Self {
163        self.attr(
164            Attribute::Content,
165            AttrValue::Payload(PropPayload::Vec(
166                choices
167                    .iter()
168                    .map(|x| PropValue::Str(x.as_ref().to_string()))
169                    .collect(),
170            )),
171        );
172        self
173    }
174
175    pub fn value(mut self, i: usize) -> Self {
176        // Set state
177        self.attr(
178            Attribute::Value,
179            AttrValue::Payload(PropPayload::One(PropValue::Usize(i))),
180        );
181        self
182    }
183
184    /// ### render_open_tab
185    ///
186    /// Render component when tab is open
187    fn render_open_tab(&mut self, render: &mut Frame, area: Rect) {
188        // Make choices
189        let choices: Vec<ListItem> = self
190            .states
191            .choices
192            .iter()
193            .map(|x| ListItem::new(Spans::from(x.clone())))
194            .collect();
195        let foreground = self
196            .props
197            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
198            .unwrap_color();
199        let background = self
200            .props
201            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
202            .unwrap_color();
203        let hg: Color = self
204            .props
205            .get_or(Attribute::HighlightedColor, AttrValue::Color(foreground))
206            .unwrap_color();
207        // Prepare layout
208        let chunks = Layout::default()
209            .direction(LayoutDirection::Vertical)
210            .margin(0)
211            .constraints([Constraint::Length(2), Constraint::Min(1)].as_ref())
212            .split(area);
213        // Render like "closed" tab in chunk 0
214        let selected_text: String = match self.states.choices.get(self.states.selected) {
215            None => String::default(),
216            Some(s) => s.clone(),
217        };
218        let borders = self
219            .props
220            .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
221            .unwrap_borders();
222        let block: Block = Block::default()
223            .borders(BorderSides::LEFT | BorderSides::TOP | BorderSides::RIGHT)
224            .border_style(borders.style())
225            .border_type(borders.modifiers)
226            .style(Style::default().bg(background));
227        let title = self.props.get(Attribute::Title).map(|x| x.unwrap_title());
228        let block = match title {
229            Some((text, alignment)) => block.title(text).title_alignment(alignment),
230            None => block,
231        };
232        let focus = self
233            .props
234            .get_or(Attribute::Focus, AttrValue::Flag(false))
235            .unwrap_flag();
236        let inactive_style = self
237            .props
238            .get(Attribute::FocusStyle)
239            .map(|x| x.unwrap_style());
240        let p: Paragraph = Paragraph::new(selected_text)
241            .style(match focus {
242                true => borders.style(),
243                false => inactive_style.unwrap_or_default(),
244            })
245            .block(block);
246        render.render_widget(p, chunks[0]);
247        // Render the list of elements in chunks [1]
248        // Make list
249        let mut list = List::new(choices)
250            .block(
251                Block::default()
252                    .borders(BorderSides::LEFT | BorderSides::BOTTOM | BorderSides::RIGHT)
253                    .border_style(match focus {
254                        true => borders.style(),
255                        false => Style::default(),
256                    })
257                    .border_type(borders.modifiers)
258                    .style(Style::default().bg(background)),
259            )
260            .direction(tuirealm::ratatui::widgets::ListDirection::TopToBottom)
261            .style(Style::default().fg(foreground).bg(background))
262            .highlight_style(
263                Style::default()
264                    .fg(hg)
265                    .add_modifier(TextModifiers::REVERSED),
266            );
267        // Highlighted symbol
268        self.hg_str = self
269            .props
270            .get(Attribute::HighlightedStr)
271            .map(|x| x.unwrap_string());
272        if let Some(hg_str) = &self.hg_str {
273            list = list.highlight_symbol(hg_str);
274        }
275        let mut state: ListState = ListState::default();
276        state.select(Some(self.states.selected));
277        render.render_stateful_widget(list, chunks[1], &mut state);
278    }
279
280    /// ### render_closed_tab
281    ///
282    /// Render component when tab is closed
283    fn render_closed_tab(&self, render: &mut Frame, area: Rect) {
284        let foreground = self
285            .props
286            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
287            .unwrap_color();
288        let background = self
289            .props
290            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
291            .unwrap_color();
292        let inactive_style = self
293            .props
294            .get(Attribute::FocusStyle)
295            .map(|x| x.unwrap_style());
296        let focus = self
297            .props
298            .get_or(Attribute::Focus, AttrValue::Flag(false))
299            .unwrap_flag();
300        let style = match focus {
301            true => Style::default().bg(background).fg(foreground),
302            false => inactive_style.unwrap_or_default(),
303        };
304        let borders = self
305            .props
306            .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
307            .unwrap_borders();
308        let borders_style = match focus {
309            true => borders.style(),
310            false => inactive_style.unwrap_or_default(),
311        };
312        let block: Block = Block::default()
313            .borders(BorderSides::ALL)
314            .border_style(borders_style)
315            .border_type(borders.modifiers)
316            .style(style);
317        let title = self.props.get(Attribute::Title).map(|x| x.unwrap_title());
318        let block = match title {
319            Some((text, alignment)) => block.title(text).title_alignment(alignment),
320            None => block,
321        };
322        let selected_text: String = match self.states.choices.get(self.states.selected) {
323            None => String::default(),
324            Some(s) => s.clone(),
325        };
326        let p: Paragraph = Paragraph::new(selected_text).style(style).block(block);
327        render.render_widget(p, area);
328    }
329
330    fn rewindable(&self) -> bool {
331        self.props
332            .get_or(Attribute::Rewind, AttrValue::Flag(false))
333            .unwrap_flag()
334    }
335}
336
337impl MockComponent for Select {
338    fn view(&mut self, render: &mut Frame, area: Rect) {
339        if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) {
340            match self.states.is_tab_open() {
341                true => self.render_open_tab(render, area),
342                false => self.render_closed_tab(render, area),
343            }
344        }
345    }
346
347    fn query(&self, attr: Attribute) -> Option<AttrValue> {
348        self.props.get(attr)
349    }
350
351    fn attr(&mut self, attr: Attribute, value: AttrValue) {
352        match attr {
353            Attribute::Content => {
354                // Reset choices
355                let choices: Vec<String> = value
356                    .unwrap_payload()
357                    .unwrap_vec()
358                    .iter()
359                    .map(|x| x.clone().unwrap_str())
360                    .collect();
361                self.states.set_choices(&choices);
362            }
363            Attribute::Value => {
364                self.states
365                    .select(value.unwrap_payload().unwrap_one().unwrap_usize());
366            }
367            Attribute::Focus if self.states.is_tab_open() => {
368                if let AttrValue::Flag(false) = value {
369                    self.states.cancel_tab();
370                }
371                self.props.set(attr, value);
372            }
373            attr => {
374                self.props.set(attr, value);
375            }
376        }
377    }
378
379    fn state(&self) -> State {
380        if self.states.is_tab_open() {
381            State::None
382        } else {
383            State::One(StateValue::Usize(self.states.selected))
384        }
385    }
386
387    fn perform(&mut self, cmd: Cmd) -> CmdResult {
388        match cmd {
389            Cmd::Move(Direction::Down) => {
390                // Increment choice
391                self.states.next_choice(self.rewindable());
392                // Return CmdResult On Change or None if tab is closed
393                match self.states.is_tab_open() {
394                    false => CmdResult::None,
395                    true => CmdResult::Changed(State::One(StateValue::Usize(self.states.selected))),
396                }
397            }
398            Cmd::Move(Direction::Up) => {
399                // Increment choice
400                self.states.prev_choice(self.rewindable());
401                // Return CmdResult On Change or None if tab is closed
402                match self.states.is_tab_open() {
403                    false => CmdResult::None,
404                    true => CmdResult::Changed(State::One(StateValue::Usize(self.states.selected))),
405                }
406            }
407            Cmd::Cancel => {
408                self.states.cancel_tab();
409                CmdResult::Changed(self.state())
410            }
411            Cmd::Submit => {
412                // Open or close tab
413                if self.states.is_tab_open() {
414                    self.states.close_tab();
415                    CmdResult::Submit(self.state())
416                } else {
417                    self.states.open_tab();
418                    CmdResult::None
419                }
420            }
421            _ => CmdResult::None,
422        }
423    }
424}
425
426#[cfg(test)]
427mod test {
428
429    use super::*;
430
431    use pretty_assertions::assert_eq;
432
433    use tuirealm::props::{PropPayload, PropValue};
434
435    #[test]
436    fn test_components_select_states() {
437        let mut states: SelectStates = SelectStates::default();
438        assert_eq!(states.selected, 0);
439        assert_eq!(states.choices.len(), 0);
440        assert_eq!(states.tab_open, false);
441        let choices: &[String] = &[
442            "lemon".to_string(),
443            "strawberry".to_string(),
444            "vanilla".to_string(),
445            "chocolate".to_string(),
446        ];
447        states.set_choices(&choices);
448        assert_eq!(states.selected, 0);
449        assert_eq!(states.choices.len(), 4);
450        // Move
451        states.prev_choice(false);
452        assert_eq!(states.selected, 0);
453        states.next_choice(false);
454        // Tab is closed!!!
455        assert_eq!(states.selected, 0);
456        states.open_tab();
457        assert_eq!(states.is_tab_open(), true);
458        // Now we can move
459        states.next_choice(false);
460        assert_eq!(states.selected, 1);
461        states.next_choice(false);
462        assert_eq!(states.selected, 2);
463        // Forward overflow
464        states.next_choice(false);
465        states.next_choice(false);
466        assert_eq!(states.selected, 3);
467        states.prev_choice(false);
468        assert_eq!(states.selected, 2);
469        // Close tab
470        states.close_tab();
471        assert_eq!(states.is_tab_open(), false);
472        states.prev_choice(false);
473        assert_eq!(states.selected, 2);
474        // Update
475        let choices: &[String] = &["lemon".to_string(), "strawberry".to_string()];
476        states.set_choices(&choices);
477        assert_eq!(states.selected, 1); // Move to first index available
478        assert_eq!(states.choices.len(), 2);
479        let choices = vec![];
480        states.set_choices(&choices);
481        assert_eq!(states.selected, 0); // Move to first index available
482        assert_eq!(states.choices.len(), 0);
483        // Rewind
484        let choices: &[String] = &[
485            "lemon".to_string(),
486            "strawberry".to_string(),
487            "vanilla".to_string(),
488            "chocolate".to_string(),
489        ];
490        states.set_choices(choices);
491        states.open_tab();
492        assert_eq!(states.selected, 0);
493        states.prev_choice(true);
494        assert_eq!(states.selected, 3);
495        states.next_choice(true);
496        assert_eq!(states.selected, 0);
497        states.next_choice(true);
498        assert_eq!(states.selected, 1);
499        states.prev_choice(true);
500        assert_eq!(states.selected, 0);
501        // Cancel tab
502        states.close_tab();
503        states.select(2);
504        states.open_tab();
505        states.prev_choice(true);
506        states.prev_choice(true);
507        assert_eq!(states.selected, 0);
508        states.cancel_tab();
509        assert_eq!(states.selected, 2);
510        assert_eq!(states.is_tab_open(), false);
511    }
512
513    #[test]
514    fn test_components_select() {
515        // Make component
516        let mut component = Select::default()
517            .foreground(Color::Red)
518            .background(Color::Black)
519            .borders(Borders::default())
520            .highlighted_color(Color::Red)
521            .highlighted_str(">>")
522            .title("C'est oui ou bien c'est non?", Alignment::Center)
523            .choices(&["Oui!", "Non", "Peut-ĂȘtre"])
524            .value(1)
525            .rewind(false);
526        assert_eq!(component.states.is_tab_open(), false);
527        component.states.open_tab();
528        assert_eq!(component.states.is_tab_open(), true);
529        component.states.close_tab();
530        assert_eq!(component.states.is_tab_open(), false);
531        // Update
532        component.attr(
533            Attribute::Value,
534            AttrValue::Payload(PropPayload::One(PropValue::Usize(2))),
535        );
536        // Get value
537        assert_eq!(component.state(), State::One(StateValue::Usize(2)));
538        // Open tab
539        component.states.open_tab();
540        // Events
541        // Move cursor
542        assert_eq!(
543            component.perform(Cmd::Move(Direction::Up)),
544            CmdResult::Changed(State::One(StateValue::Usize(1))),
545        );
546        assert_eq!(
547            component.perform(Cmd::Move(Direction::Up)),
548            CmdResult::Changed(State::One(StateValue::Usize(0))),
549        );
550        // Upper boundary
551        assert_eq!(
552            component.perform(Cmd::Move(Direction::Up)),
553            CmdResult::Changed(State::One(StateValue::Usize(0))),
554        );
555        // Move down
556        assert_eq!(
557            component.perform(Cmd::Move(Direction::Down)),
558            CmdResult::Changed(State::One(StateValue::Usize(1))),
559        );
560        assert_eq!(
561            component.perform(Cmd::Move(Direction::Down)),
562            CmdResult::Changed(State::One(StateValue::Usize(2))),
563        );
564        // Lower boundary
565        assert_eq!(
566            component.perform(Cmd::Move(Direction::Down)),
567            CmdResult::Changed(State::One(StateValue::Usize(2))),
568        );
569        // Press enter
570        assert_eq!(
571            component.perform(Cmd::Submit),
572            CmdResult::Submit(State::One(StateValue::Usize(2))),
573        );
574        // Tab should be closed
575        assert_eq!(component.states.is_tab_open(), false);
576        // Re open
577        assert_eq!(component.perform(Cmd::Submit), CmdResult::None);
578        assert_eq!(component.states.is_tab_open(), true);
579        // Move arrows
580        assert_eq!(
581            component.perform(Cmd::Submit),
582            CmdResult::Submit(State::One(StateValue::Usize(2))),
583        );
584        assert_eq!(component.states.is_tab_open(), false);
585        assert_eq!(
586            component.perform(Cmd::Move(Direction::Down)),
587            CmdResult::None
588        );
589        assert_eq!(component.perform(Cmd::Move(Direction::Up)), CmdResult::None);
590    }
591}