tui/components/
radio_select.rs1use 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
9pub 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); }
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); }
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}