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