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