Skip to main content

tui_realm_stdlib/components/
select.rs

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