Skip to main content

tui_realm_stdlib/components/
radio.rs

1//! `Radio` component renders a radio group.
2
3/**
4 * MIT License
5 *
6 * termscp - Copyright (c) 2021 Christian Visintin
7 *
8 * Permission is hereby granted, free of charge, to any person obtaining a copy
9 * of this software and associated documentation files (the "Software"), to deal
10 * in the Software without restriction, including without limitation the rights
11 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12 * copies of the Software, and to permit persons to whom the Software is
13 * furnished to do so, subject to the following conditions:
14 *
15 * The above copyright notice and this permission notice shall be included in all
16 * copies or substantial portions of the Software.
17 *
18 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24 * SOFTWARE.
25 */
26use tuirealm::command::{Cmd, CmdResult, Direction};
27use tuirealm::component::Component;
28use tuirealm::props::{
29    AttrValue, Attribute, Borders, Color, PropPayload, PropValue, Props, QueryResult, Style,
30    TextModifiers, Title,
31};
32use tuirealm::ratatui::Frame;
33use tuirealm::ratatui::layout::Rect;
34use tuirealm::ratatui::text::Line;
35use tuirealm::ratatui::widgets::Tabs;
36use tuirealm::state::{State, StateValue};
37
38use crate::prop_ext::{CommonHighlight, CommonProps};
39
40// -- states
41
42/// The state that needs to be kept for the [`Radio`] component.
43#[derive(Default)]
44pub struct RadioStates {
45    /// Selected option.
46    pub choice: usize,
47    /// Available choices.
48    pub choices: Vec<String>,
49}
50
51impl RadioStates {
52    /// Move choice index to next choice
53    pub fn next_choice(&mut self, rewind: bool) {
54        if rewind && self.choice + 1 >= self.choices.len() {
55            self.choice = 0;
56        } else if self.choice + 1 < self.choices.len() {
57            self.choice += 1;
58        }
59    }
60
61    /// Move choice index to previous choice.
62    pub fn prev_choice(&mut self, rewind: bool) {
63        if rewind && self.choice == 0 && !self.choices.is_empty() {
64            self.choice = self.choices.len() - 1;
65        } else if self.choice > 0 {
66            self.choice -= 1;
67        }
68    }
69
70    /// Overwrite the choices available with new ones.
71    ///
72    /// In addition resets current selection and keep index if possible or set it to the first value
73    /// available.
74    pub fn set_choices(&mut self, choices: impl Into<Vec<String>>) {
75        self.choices = choices.into();
76        // Keep index if possible
77        if self.choice >= self.choices.len() {
78            self.choice = match self.choices.len() {
79                0 => 0,
80                l => l - 1,
81            };
82        }
83    }
84
85    /// Select a specific choice.
86    pub fn select(&mut self, i: usize) {
87        if i < self.choices.len() {
88            self.choice = i;
89        }
90    }
91}
92
93// -- component
94
95/// The radio component is a single-choice selector.
96///
97/// Use [`Checkbox`](crate::components::Checkbox) if a multi-choice selector is wanted.
98#[derive(Default)]
99#[must_use]
100pub struct Radio {
101    common: CommonProps,
102    common_hg: CommonHighlight,
103    props: Props,
104    pub states: RadioStates,
105}
106
107impl Radio {
108    /// Set the main foreground color. This may get overwritten by individual text styles.
109    pub fn foreground(mut self, fg: Color) -> Self {
110        self.attr(Attribute::Foreground, AttrValue::Color(fg));
111        self
112    }
113
114    /// Set the main background color. This may get overwritten by individual text styles.
115    pub fn background(mut self, bg: Color) -> Self {
116        self.attr(Attribute::Background, AttrValue::Color(bg));
117        self
118    }
119
120    /// Set the main text modifiers. This may get overwritten by individual text styles.
121    pub fn modifiers(mut self, m: TextModifiers) -> Self {
122        self.attr(Attribute::TextProps, AttrValue::TextModifiers(m));
123        self
124    }
125
126    /// Set the main style. This may get overwritten by individual text styles.
127    ///
128    /// This option will overwrite any previous [`foreground`](Self::foreground), [`background`](Self::background) and [`modifiers`](Self::modifiers)!
129    pub fn style(mut self, style: Style) -> Self {
130        self.attr(Attribute::Style, AttrValue::Style(style));
131        self
132    }
133
134    /// Set a custom style for the border when the component is unfocused.
135    pub fn inactive(mut self, s: Style) -> Self {
136        self.attr(Attribute::UnfocusedBorderStyle, AttrValue::Style(s));
137        self
138    }
139
140    /// Add a border to the component.
141    pub fn borders(mut self, b: Borders) -> Self {
142        self.attr(Attribute::Borders, AttrValue::Borders(b));
143        self
144    }
145
146    /// Add a title to the component.
147    pub fn title<T: Into<Title>>(mut self, title: T) -> Self {
148        self.attr(Attribute::Title, AttrValue::Title(title.into()));
149        self
150    }
151
152    /// Set a custom highlight style that is patched on-top of the normal style.
153    ///
154    /// By default the highlight style is just `Style::new().add_modifier(Modifier::REVERSED)`.
155    pub fn highlight_style(mut self, s: Style) -> Self {
156        self.attr(Attribute::HighlightStyle, AttrValue::Style(s));
157        self
158    }
159
160    /// Set a custom highlight style that is patched on-top of the highlight style when unfocused.
161    pub fn highlight_style_inactive(mut self, s: Style) -> Self {
162        self.attr(Attribute::HighlightStyleUnfocused, AttrValue::Style(s));
163        self
164    }
165
166    /// Set whether wraparound should be possible (down on the last choice wraps around to 0, and the other way around).
167    pub fn rewind(mut self, r: bool) -> Self {
168        self.attr(Attribute::Rewind, AttrValue::Flag(r));
169        self
170    }
171
172    /// Set the choices that should be possible.
173    pub fn choices<S: Into<String>>(mut self, choices: impl IntoIterator<Item = S>) -> Self {
174        // TODO: we should consider using Spans or Lines
175        self.attr(
176            Attribute::Content,
177            AttrValue::Payload(PropPayload::Vec(
178                choices
179                    .into_iter()
180                    .map(|v| PropValue::Str(v.into()))
181                    .collect(),
182            )),
183        );
184        self
185    }
186
187    /// Set the initially selected choice.
188    pub fn value(mut self, i: usize) -> Self {
189        // Set state
190        self.attr(
191            Attribute::Value,
192            AttrValue::Payload(PropPayload::Single(PropValue::Usize(i))),
193        );
194        self
195    }
196
197    /// Set the current component to be always active (show highligh even if unfocused)
198    pub fn always_active(mut self) -> Self {
199        self.attr(Attribute::AlwaysActive, AttrValue::Flag(true));
200        self
201    }
202
203    fn is_rewind(&self) -> bool {
204        self.props
205            .get(Attribute::Rewind)
206            .and_then(AttrValue::as_flag)
207            .unwrap_or_default()
208    }
209}
210
211impl Component for Radio {
212    fn view(&mut self, render: &mut Frame, area: Rect) {
213        if !self.common.display {
214            return;
215        }
216
217        // Make choices
218        let choices: Vec<Line> = self
219            .states
220            .choices
221            .iter()
222            .map(|x| Line::from(x.as_str()))
223            .collect();
224
225        let mut widget = Tabs::new(choices)
226            .select(self.states.choice)
227            .style(self.common.style)
228            .highlight_style(
229                self.common_hg
230                    .get_style_focus(self.common.style, self.common.is_active()),
231            );
232
233        if let Some(block) = self.common.get_block() {
234            widget = widget.block(block);
235        }
236
237        render.render_widget(widget, area);
238    }
239
240    fn query<'a>(&'a self, attr: Attribute) -> Option<QueryResult<'a>> {
241        if let Some(value) = self
242            .common
243            .get_for_query(attr)
244            .or_else(|| self.common_hg.get_for_query(attr))
245        {
246            return Some(value);
247        }
248
249        self.props.get_for_query(attr)
250    }
251
252    fn attr(&mut self, attr: Attribute, value: AttrValue) {
253        if let Some(value) = self
254            .common
255            .set(attr, value)
256            .and_then(|value| self.common_hg.set(attr, value))
257        {
258            match attr {
259                Attribute::Content => {
260                    // Reset choices
261                    let choices: Vec<String> = value
262                        .unwrap_payload()
263                        .unwrap_vec()
264                        .iter()
265                        .map(|x| x.clone().unwrap_str())
266                        .collect();
267                    self.states.set_choices(choices);
268                }
269                Attribute::Value => {
270                    self.states
271                        .select(value.unwrap_payload().unwrap_single().unwrap_usize());
272                }
273                attr => {
274                    self.props.set(attr, value);
275                }
276            }
277        }
278    }
279
280    fn state(&self) -> State {
281        State::Single(StateValue::Usize(self.states.choice))
282    }
283
284    fn perform(&mut self, cmd: Cmd) -> CmdResult {
285        match cmd {
286            Cmd::Move(Direction::Right) => {
287                // Increment choice
288                self.states.next_choice(self.is_rewind());
289                // Return CmdResult On Change
290                CmdResult::Changed(self.state())
291            }
292            Cmd::Move(Direction::Left) => {
293                // Decrement choice
294                self.states.prev_choice(self.is_rewind());
295                // Return CmdResult On Change
296                CmdResult::Changed(self.state())
297            }
298            Cmd::Submit => {
299                // Return Submit
300                CmdResult::Submit(self.state())
301            }
302            _ => CmdResult::Invalid(cmd),
303        }
304    }
305}
306
307#[cfg(test)]
308mod test {
309
310    use pretty_assertions::assert_eq;
311    use tuirealm::props::{HorizontalAlignment, PropPayload, PropValue};
312
313    use super::*;
314
315    #[test]
316    fn test_components_radio_states() {
317        let mut states: RadioStates = RadioStates::default();
318        assert_eq!(states.choice, 0);
319        assert_eq!(states.choices.len(), 0);
320        let choices: &[String] = &[
321            "lemon".to_string(),
322            "strawberry".to_string(),
323            "vanilla".to_string(),
324            "chocolate".to_string(),
325        ];
326        states.set_choices(choices);
327        assert_eq!(states.choice, 0);
328        assert_eq!(states.choices.len(), 4);
329        // Move
330        states.prev_choice(false);
331        assert_eq!(states.choice, 0);
332        states.next_choice(false);
333        assert_eq!(states.choice, 1);
334        states.next_choice(false);
335        assert_eq!(states.choice, 2);
336        // Forward overflow
337        states.next_choice(false);
338        states.next_choice(false);
339        assert_eq!(states.choice, 3);
340        states.prev_choice(false);
341        assert_eq!(states.choice, 2);
342        // Update
343        let choices: &[String] = &["lemon".to_string(), "strawberry".to_string()];
344        states.set_choices(choices);
345        assert_eq!(states.choice, 1); // Move to first index available
346        assert_eq!(states.choices.len(), 2);
347        let choices: &[String] = &[];
348        states.set_choices(choices);
349        assert_eq!(states.choice, 0); // Move to first index available
350        assert_eq!(states.choices.len(), 0);
351        // Rewind
352        let choices: &[String] = &[
353            "lemon".to_string(),
354            "strawberry".to_string(),
355            "vanilla".to_string(),
356            "chocolate".to_string(),
357        ];
358        states.set_choices(choices);
359        assert_eq!(states.choice, 0);
360        states.prev_choice(true);
361        assert_eq!(states.choice, 3);
362        states.next_choice(true);
363        assert_eq!(states.choice, 0);
364        states.next_choice(true);
365        assert_eq!(states.choice, 1);
366        states.prev_choice(true);
367        assert_eq!(states.choice, 0);
368    }
369
370    #[test]
371    fn test_components_radio() {
372        // Make component
373        let mut component = Radio::default()
374            .background(Color::Blue)
375            .foreground(Color::Red)
376            .borders(Borders::default())
377            .title(
378                Title::from("C'est oui ou bien c'est non?").alignment(HorizontalAlignment::Center),
379            )
380            .choices(["Oui!", "Non", "Peut-ĂȘtre"])
381            .value(1)
382            .rewind(false);
383        // Verify states
384        assert_eq!(component.states.choice, 1);
385        assert_eq!(component.states.choices.len(), 3);
386        component.attr(
387            Attribute::Value,
388            AttrValue::Payload(PropPayload::Single(PropValue::Usize(2))),
389        );
390        assert_eq!(component.state(), State::Single(StateValue::Usize(2)));
391        // Get value
392        component.states.choice = 1;
393        assert_eq!(component.state(), State::Single(StateValue::Usize(1)));
394        // Handle events
395        assert_eq!(
396            component.perform(Cmd::Move(Direction::Left)),
397            CmdResult::Changed(State::Single(StateValue::Usize(0))),
398        );
399        assert_eq!(component.state(), State::Single(StateValue::Usize(0)));
400        // Left again
401        assert_eq!(
402            component.perform(Cmd::Move(Direction::Left)),
403            CmdResult::Changed(State::Single(StateValue::Usize(0))),
404        );
405        assert_eq!(component.state(), State::Single(StateValue::Usize(0)));
406        // Right
407        assert_eq!(
408            component.perform(Cmd::Move(Direction::Right)),
409            CmdResult::Changed(State::Single(StateValue::Usize(1))),
410        );
411        assert_eq!(component.state(), State::Single(StateValue::Usize(1)));
412        // Right again
413        assert_eq!(
414            component.perform(Cmd::Move(Direction::Right)),
415            CmdResult::Changed(State::Single(StateValue::Usize(2))),
416        );
417        assert_eq!(component.state(), State::Single(StateValue::Usize(2)));
418        // Right again
419        assert_eq!(
420            component.perform(Cmd::Move(Direction::Right)),
421            CmdResult::Changed(State::Single(StateValue::Usize(2))),
422        );
423        assert_eq!(component.state(), State::Single(StateValue::Usize(2)));
424        // Submit
425        assert_eq!(
426            component.perform(Cmd::Submit),
427            CmdResult::Submit(State::Single(StateValue::Usize(2))),
428        );
429    }
430
431    #[test]
432    fn various_set_choice_types() {
433        // static array of strings
434        RadioStates::default().set_choices(&["hello".to_string()]);
435        // vector of strings
436        RadioStates::default().set_choices(vec!["hello".to_string()]);
437        // boxed array of strings
438        RadioStates::default().set_choices(vec!["hello".to_string()].into_boxed_slice());
439    }
440
441    #[test]
442    fn various_choice_types() {
443        // static array of static strings
444        let _ = Radio::default().choices(["hello"]);
445        // static array of strings
446        let _ = Radio::default().choices(["hello".to_string()]);
447        // vec of static strings
448        let _ = Radio::default().choices(vec!["hello"]);
449        // vec of strings
450        let _ = Radio::default().choices(vec!["hello".to_string()]);
451        // boxed array of static strings
452        let _ = Radio::default().choices(vec!["hello"].into_boxed_slice());
453        // boxed array of strings
454        let _ = Radio::default().choices(vec!["hello".to_string()].into_boxed_slice());
455    }
456}