Skip to main content

tui/components/
radio_select.rs

1use crossterm::event::KeyCode;
2
3use super::select_option::SelectOption;
4use crate::components::{Component, Event, ViewContext};
5use crate::line::Line;
6use crate::rendering::frame::Frame;
7use crate::style::Style;
8
9/// Single-select from a list of options, rendered as radio buttons.
10pub struct RadioSelect {
11    pub options: Vec<SelectOption>,
12    pub selected: usize,
13}
14
15impl RadioSelect {
16    pub fn new(options: Vec<SelectOption>, selected: usize) -> Self {
17        Self { options, selected }
18    }
19
20    pub fn to_json(&self) -> serde_json::Value {
21        self.options
22            .get(self.selected)
23            .map_or(serde_json::Value::Null, |o| {
24                serde_json::Value::String(o.value.clone())
25            })
26    }
27
28    fn render_inline(&self, context: &ViewContext) -> Line {
29        if let Some(opt) = self.options.get(self.selected) {
30            Line::styled(&opt.title, context.theme.info())
31        } else {
32            Line::default()
33        }
34    }
35
36    fn render_options(&self, context: &ViewContext) -> Vec<Line> {
37        self.options
38            .iter()
39            .enumerate()
40            .map(|(j, opt)| {
41                let marker = if j == self.selected { "● " } else { "○ " };
42                let style = if j == self.selected {
43                    Style::fg(context.theme.primary())
44                } else {
45                    Style::default()
46                };
47                Line::with_style(format!("{marker}{}", opt.title), style)
48            })
49            .collect()
50    }
51}
52
53impl Component for RadioSelect {
54    type Message = ();
55
56    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
57        let Event::Key(key) = event else {
58            return None;
59        };
60        if self.options.is_empty() {
61            return None;
62        }
63
64        match key.code {
65            KeyCode::Up => {
66                self.selected = (self.selected + self.options.len() - 1) % self.options.len();
67                Some(vec![])
68            }
69            KeyCode::Down => {
70                self.selected = (self.selected + 1) % self.options.len();
71                Some(vec![])
72            }
73            _ => None,
74        }
75    }
76
77    fn render(&mut self, context: &ViewContext) -> Frame {
78        Frame::new(self.render_field(context, true))
79    }
80}
81
82impl RadioSelect {
83    pub fn render_field(&self, context: &ViewContext, focused: bool) -> Vec<Line> {
84        if focused {
85            self.render_options(context)
86        } else {
87            vec![self.render_inline(context)]
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crossterm::event::{KeyEvent, KeyModifiers};
96
97    fn key(code: KeyCode) -> KeyEvent {
98        KeyEvent::new(code, KeyModifiers::NONE)
99    }
100
101    fn sample_options() -> Vec<SelectOption> {
102        vec![
103            SelectOption {
104                value: "a".into(),
105                title: "Alpha".into(),
106                description: None,
107            },
108            SelectOption {
109                value: "b".into(),
110                title: "Beta".into(),
111                description: None,
112            },
113            SelectOption {
114                value: "c".into(),
115                title: "Gamma".into(),
116                description: None,
117            },
118        ]
119    }
120
121    #[tokio::test]
122    async fn down_cycles_forward() {
123        let mut rs = RadioSelect::new(sample_options(), 0);
124        rs.on_event(&Event::Key(key(KeyCode::Down))).await;
125        assert_eq!(rs.selected, 1);
126        rs.on_event(&Event::Key(key(KeyCode::Down))).await;
127        assert_eq!(rs.selected, 2);
128        rs.on_event(&Event::Key(key(KeyCode::Down))).await;
129        assert_eq!(rs.selected, 0); // wraps
130    }
131
132    #[tokio::test]
133    async fn up_cycles_backward() {
134        let mut rs = RadioSelect::new(sample_options(), 0);
135        rs.on_event(&Event::Key(key(KeyCode::Up))).await;
136        assert_eq!(rs.selected, 2); // wraps to end
137    }
138
139    #[tokio::test]
140    async fn left_right_ignored() {
141        let mut rs = RadioSelect::new(sample_options(), 0);
142        let outcome = rs.on_event(&Event::Key(key(KeyCode::Right))).await;
143        assert!(outcome.is_none());
144        assert_eq!(rs.selected, 0);
145        let outcome = rs.on_event(&Event::Key(key(KeyCode::Left))).await;
146        assert!(outcome.is_none());
147        assert_eq!(rs.selected, 0);
148    }
149
150    #[test]
151    fn to_json_returns_selected_value() {
152        let rs = RadioSelect::new(sample_options(), 1);
153        assert_eq!(rs.to_json(), serde_json::json!("b"));
154    }
155
156    #[test]
157    fn to_json_empty_options_returns_null() {
158        let rs = RadioSelect::new(vec![], 0);
159        assert_eq!(rs.to_json(), serde_json::Value::Null);
160    }
161
162    #[tokio::test]
163    async fn empty_options_ignores_keys() {
164        let mut rs = RadioSelect::new(vec![], 0);
165        let outcome = rs.on_event(&Event::Key(key(KeyCode::Right))).await;
166        assert!(outcome.is_none());
167    }
168}