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 ontop 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 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 choices that should be possible.
167    pub fn choices<S: Into<String>>(mut self, choices: impl IntoIterator<Item = S>) -> Self {
168        // TODO: we should consider using Spans or Lines
169        self.attr(
170            Attribute::Content,
171            AttrValue::Payload(PropPayload::Vec(
172                choices
173                    .into_iter()
174                    .map(|v| PropValue::Str(v.into()))
175                    .collect(),
176            )),
177        );
178        self
179    }
180
181    /// Set the initially selected choice.
182    pub fn value(mut self, i: usize) -> Self {
183        // Set state
184        self.attr(
185            Attribute::Value,
186            AttrValue::Payload(PropPayload::Single(PropValue::Usize(i))),
187        );
188        self
189    }
190
191    /// Set the current component to be always active (show highligh even if unfocused)
192    pub fn always_active(mut self) -> Self {
193        self.attr(Attribute::AlwaysActive, AttrValue::Flag(true));
194        self
195    }
196
197    fn is_rewind(&self) -> bool {
198        self.props
199            .get(Attribute::Rewind)
200            .and_then(AttrValue::as_flag)
201            .unwrap_or_default()
202    }
203}
204
205impl Component for Radio {
206    fn view(&mut self, render: &mut Frame, area: Rect) {
207        if !self.common.display {
208            return;
209        }
210
211        // Make choices
212        let choices: Vec<Line> = self
213            .states
214            .choices
215            .iter()
216            .map(|x| Line::from(x.as_str()))
217            .collect();
218
219        let mut widget = Tabs::new(choices)
220            .select(self.states.choice)
221            .style(self.common.style);
222
223        if self.common.is_active() {
224            widget = widget.highlight_style(self.common_hg.get_style(self.common.style));
225        } else {
226            // for some reason, it seems like "Tabs" has a default "REVERSED" set for the highlight style
227            // at least as of ratatui-widgets 0.30
228            widget = widget.highlight_style(Style::default());
229        }
230
231        if let Some(block) = self.common.get_block() {
232            widget = widget.block(block);
233        }
234
235        render.render_widget(widget, area);
236    }
237
238    fn query<'a>(&'a self, attr: Attribute) -> Option<QueryResult<'a>> {
239        if let Some(value) = self
240            .common
241            .get_for_query(attr)
242            .or_else(|| self.common_hg.get_for_query(attr))
243        {
244            return Some(value);
245        }
246
247        self.props.get_for_query(attr)
248    }
249
250    fn attr(&mut self, attr: Attribute, value: AttrValue) {
251        if let Some(value) = self
252            .common
253            .set(attr, value)
254            .and_then(|value| self.common_hg.set(attr, value))
255        {
256            match attr {
257                Attribute::Content => {
258                    // Reset choices
259                    let choices: Vec<String> = value
260                        .unwrap_payload()
261                        .unwrap_vec()
262                        .iter()
263                        .map(|x| x.clone().unwrap_str())
264                        .collect();
265                    self.states.set_choices(choices);
266                }
267                Attribute::Value => {
268                    self.states
269                        .select(value.unwrap_payload().unwrap_single().unwrap_usize());
270                }
271                attr => {
272                    self.props.set(attr, value);
273                }
274            }
275        }
276    }
277
278    fn state(&self) -> State {
279        State::Single(StateValue::Usize(self.states.choice))
280    }
281
282    fn perform(&mut self, cmd: Cmd) -> CmdResult {
283        match cmd {
284            Cmd::Move(Direction::Right) => {
285                // Increment choice
286                self.states.next_choice(self.is_rewind());
287                // Return CmdResult On Change
288                CmdResult::Changed(self.state())
289            }
290            Cmd::Move(Direction::Left) => {
291                // Decrement choice
292                self.states.prev_choice(self.is_rewind());
293                // Return CmdResult On Change
294                CmdResult::Changed(self.state())
295            }
296            Cmd::Submit => {
297                // Return Submit
298                CmdResult::Submit(self.state())
299            }
300            _ => CmdResult::Invalid(cmd),
301        }
302    }
303}
304
305#[cfg(test)]
306mod test {
307
308    use pretty_assertions::assert_eq;
309    use tuirealm::props::{HorizontalAlignment, PropPayload, PropValue};
310
311    use super::*;
312
313    #[test]
314    fn test_components_radio_states() {
315        let mut states: RadioStates = RadioStates::default();
316        assert_eq!(states.choice, 0);
317        assert_eq!(states.choices.len(), 0);
318        let choices: &[String] = &[
319            "lemon".to_string(),
320            "strawberry".to_string(),
321            "vanilla".to_string(),
322            "chocolate".to_string(),
323        ];
324        states.set_choices(choices);
325        assert_eq!(states.choice, 0);
326        assert_eq!(states.choices.len(), 4);
327        // Move
328        states.prev_choice(false);
329        assert_eq!(states.choice, 0);
330        states.next_choice(false);
331        assert_eq!(states.choice, 1);
332        states.next_choice(false);
333        assert_eq!(states.choice, 2);
334        // Forward overflow
335        states.next_choice(false);
336        states.next_choice(false);
337        assert_eq!(states.choice, 3);
338        states.prev_choice(false);
339        assert_eq!(states.choice, 2);
340        // Update
341        let choices: &[String] = &["lemon".to_string(), "strawberry".to_string()];
342        states.set_choices(choices);
343        assert_eq!(states.choice, 1); // Move to first index available
344        assert_eq!(states.choices.len(), 2);
345        let choices: &[String] = &[];
346        states.set_choices(choices);
347        assert_eq!(states.choice, 0); // Move to first index available
348        assert_eq!(states.choices.len(), 0);
349        // Rewind
350        let choices: &[String] = &[
351            "lemon".to_string(),
352            "strawberry".to_string(),
353            "vanilla".to_string(),
354            "chocolate".to_string(),
355        ];
356        states.set_choices(choices);
357        assert_eq!(states.choice, 0);
358        states.prev_choice(true);
359        assert_eq!(states.choice, 3);
360        states.next_choice(true);
361        assert_eq!(states.choice, 0);
362        states.next_choice(true);
363        assert_eq!(states.choice, 1);
364        states.prev_choice(true);
365        assert_eq!(states.choice, 0);
366    }
367
368    #[test]
369    fn test_components_radio() {
370        // Make component
371        let mut component = Radio::default()
372            .background(Color::Blue)
373            .foreground(Color::Red)
374            .borders(Borders::default())
375            .title(
376                Title::from("C'est oui ou bien c'est non?").alignment(HorizontalAlignment::Center),
377            )
378            .choices(["Oui!", "Non", "Peut-ĂȘtre"])
379            .value(1)
380            .rewind(false);
381        // Verify states
382        assert_eq!(component.states.choice, 1);
383        assert_eq!(component.states.choices.len(), 3);
384        component.attr(
385            Attribute::Value,
386            AttrValue::Payload(PropPayload::Single(PropValue::Usize(2))),
387        );
388        assert_eq!(component.state(), State::Single(StateValue::Usize(2)));
389        // Get value
390        component.states.choice = 1;
391        assert_eq!(component.state(), State::Single(StateValue::Usize(1)));
392        // Handle events
393        assert_eq!(
394            component.perform(Cmd::Move(Direction::Left)),
395            CmdResult::Changed(State::Single(StateValue::Usize(0))),
396        );
397        assert_eq!(component.state(), State::Single(StateValue::Usize(0)));
398        // Left again
399        assert_eq!(
400            component.perform(Cmd::Move(Direction::Left)),
401            CmdResult::Changed(State::Single(StateValue::Usize(0))),
402        );
403        assert_eq!(component.state(), State::Single(StateValue::Usize(0)));
404        // Right
405        assert_eq!(
406            component.perform(Cmd::Move(Direction::Right)),
407            CmdResult::Changed(State::Single(StateValue::Usize(1))),
408        );
409        assert_eq!(component.state(), State::Single(StateValue::Usize(1)));
410        // Right again
411        assert_eq!(
412            component.perform(Cmd::Move(Direction::Right)),
413            CmdResult::Changed(State::Single(StateValue::Usize(2))),
414        );
415        assert_eq!(component.state(), State::Single(StateValue::Usize(2)));
416        // Right again
417        assert_eq!(
418            component.perform(Cmd::Move(Direction::Right)),
419            CmdResult::Changed(State::Single(StateValue::Usize(2))),
420        );
421        assert_eq!(component.state(), State::Single(StateValue::Usize(2)));
422        // Submit
423        assert_eq!(
424            component.perform(Cmd::Submit),
425            CmdResult::Submit(State::Single(StateValue::Usize(2))),
426        );
427    }
428
429    #[test]
430    fn various_set_choice_types() {
431        // static array of strings
432        RadioStates::default().set_choices(&["hello".to_string()]);
433        // vector of strings
434        RadioStates::default().set_choices(vec!["hello".to_string()]);
435        // boxed array of strings
436        RadioStates::default().set_choices(vec!["hello".to_string()].into_boxed_slice());
437    }
438
439    #[test]
440    fn various_choice_types() {
441        // static array of static strings
442        let _ = Radio::default().choices(["hello"]);
443        // static array of strings
444        let _ = Radio::default().choices(["hello".to_string()]);
445        // vec of static strings
446        let _ = Radio::default().choices(vec!["hello"]);
447        // vec of strings
448        let _ = Radio::default().choices(vec!["hello".to_string()]);
449        // boxed array of static strings
450        let _ = Radio::default().choices(vec!["hello"].into_boxed_slice());
451        // boxed array of strings
452        let _ = Radio::default().choices(vec!["hello".to_string()].into_boxed_slice());
453    }
454}