Skip to main content

revue/widget/input_widgets/
radio.rs

1//! Radio button widget for single selection from options
2
3use crate::event::Key;
4use crate::render::Cell;
5use crate::style::Color;
6use crate::utils::Selection;
7use crate::widget::traits::{RenderContext, View, WidgetProps};
8use crate::{impl_props_builders, impl_styled_view};
9
10/// Radio button style variants
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum RadioStyle {
13    /// Parentheses with dot: (●) ( )
14    #[default]
15    Parentheses,
16    /// Unicode radio: ◉ ○
17    Unicode,
18    /// Brackets with asterisk: \[*\] \[ \]
19    Brackets,
20    /// Diamond: ◆ ◇
21    Diamond,
22}
23
24impl RadioStyle {
25    /// Get the selected and unselected characters for this style
26    fn chars(&self) -> (char, char) {
27        match self {
28            RadioStyle::Parentheses => ('●', ' '),
29            RadioStyle::Unicode => ('◉', '○'),
30            RadioStyle::Brackets => ('*', ' '),
31            RadioStyle::Diamond => ('◆', '◇'),
32        }
33    }
34
35    /// Get the bracket characters (if applicable)
36    fn brackets(&self) -> (char, char) {
37        match self {
38            RadioStyle::Parentheses => ('(', ')'),
39            RadioStyle::Brackets => ('[', ']'),
40            _ => (' ', ' '),
41        }
42    }
43
44    /// Whether this style uses brackets
45    fn has_brackets(&self) -> bool {
46        matches!(self, RadioStyle::Parentheses | RadioStyle::Brackets)
47    }
48}
49
50/// Layout direction for radio group
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52pub enum RadioLayout {
53    /// Stack options vertically
54    #[default]
55    Vertical,
56    /// Layout options horizontally
57    Horizontal,
58}
59
60/// A radio button group widget for single selection
61#[derive(Clone)]
62pub struct RadioGroup {
63    options: Vec<String>,
64    selection: Selection,
65    focused: bool,
66    disabled: bool,
67    style: RadioStyle,
68    layout: RadioLayout,
69    gap: u16,
70    fg: Option<Color>,
71    selected_fg: Option<Color>,
72    props: WidgetProps,
73}
74
75impl RadioGroup {
76    /// Create a new radio group with options
77    pub fn new<I, S>(options: I) -> Self
78    where
79        I: IntoIterator<Item = S>,
80        S: Into<String>,
81    {
82        let opts: Vec<String> = options.into_iter().map(|s| s.into()).collect();
83        let len = opts.len();
84        Self {
85            options: opts,
86            selection: Selection::new(len),
87            focused: false,
88            disabled: false,
89            style: RadioStyle::default(),
90            layout: RadioLayout::default(),
91            gap: 0,
92            fg: None,
93            selected_fg: None,
94            props: WidgetProps::new(),
95        }
96    }
97
98    /// Set selected index
99    pub fn selected(mut self, index: usize) -> Self {
100        self.selection.set(index);
101        self
102    }
103
104    /// Set focused state
105    pub fn focused(mut self, focused: bool) -> Self {
106        self.focused = focused;
107        self
108    }
109
110    /// Set disabled state
111    pub fn disabled(mut self, disabled: bool) -> Self {
112        self.disabled = disabled;
113        self
114    }
115
116    /// Set radio style
117    pub fn style(mut self, style: RadioStyle) -> Self {
118        self.style = style;
119        self
120    }
121
122    /// Set layout direction
123    pub fn layout(mut self, layout: RadioLayout) -> Self {
124        self.layout = layout;
125        self
126    }
127
128    /// Set gap between options
129    pub fn gap(mut self, gap: u16) -> Self {
130        self.gap = gap;
131        self
132    }
133
134    /// Set label color
135    pub fn fg(mut self, color: Color) -> Self {
136        self.fg = Some(color);
137        self
138    }
139
140    /// Set selected indicator color
141    pub fn selected_fg(mut self, color: Color) -> Self {
142        self.selected_fg = Some(color);
143        self
144    }
145
146    /// Get selected index
147    pub fn selected_index(&self) -> usize {
148        self.selection.index
149    }
150
151    /// Get selected option value
152    pub fn selected_value(&self) -> Option<&str> {
153        self.options.get(self.selection.index).map(|s| s.as_str())
154    }
155
156    /// Check if focused
157    pub fn is_focused(&self) -> bool {
158        self.focused
159    }
160
161    /// Check if disabled
162    pub fn is_disabled(&self) -> bool {
163        self.disabled
164    }
165
166    /// Select next option (wraps around)
167    pub fn select_next(&mut self) {
168        if !self.disabled {
169            self.selection.next();
170        }
171    }
172
173    /// Select previous option (wraps around)
174    pub fn select_prev(&mut self) {
175        if !self.disabled {
176            self.selection.prev();
177        }
178    }
179
180    /// Set focus state (mutable)
181    pub fn set_focused(&mut self, focused: bool) {
182        self.focused = focused;
183    }
184
185    /// Set selected index (mutable)
186    pub fn set_selected(&mut self, index: usize) {
187        self.selection.set(index);
188    }
189
190    /// Handle key input, returns true if selection changed
191    pub fn handle_key(&mut self, key: &Key) -> bool {
192        if self.disabled {
193            return false;
194        }
195
196        match key {
197            Key::Up | Key::Char('k') => {
198                self.select_prev();
199                true
200            }
201            Key::Down | Key::Char('j') => {
202                self.select_next();
203                true
204            }
205            Key::Left if self.layout == RadioLayout::Horizontal => {
206                self.select_prev();
207                true
208            }
209            Key::Right if self.layout == RadioLayout::Horizontal => {
210                self.select_next();
211                true
212            }
213            Key::Char(c) if c.is_ascii_digit() => {
214                // Safe: c is '0'..='9' after is_ascii_digit() check
215                let index = (*c as u8 - b'0') as usize;
216                if index > 0 && index <= self.options.len() {
217                    self.selection.set(index - 1);
218                    true
219                } else {
220                    false
221                }
222            }
223            _ => false,
224        }
225    }
226
227    /// Render a single radio option
228    fn render_option(&self, ctx: &mut RenderContext, index: usize, x: u16, y: u16) -> u16 {
229        let area = ctx.area;
230        if x >= area.x + area.width || y >= area.y + area.height {
231            return 0;
232        }
233
234        let is_selected = self.selection.is_selected(index);
235        let (selected_char, unselected_char) = self.style.chars();
236        let (left_bracket, right_bracket) = self.style.brackets();
237        let has_brackets = self.style.has_brackets();
238
239        let label_fg = if self.disabled {
240            Color::rgb(100, 100, 100)
241        } else {
242            self.fg.unwrap_or(Color::WHITE)
243        };
244
245        let indicator_fg = if self.disabled {
246            Color::rgb(100, 100, 100)
247        } else if is_selected {
248            self.selected_fg.unwrap_or(Color::CYAN)
249        } else {
250            self.fg.unwrap_or(Color::rgb(150, 150, 150))
251        };
252
253        let mut current_x = x;
254
255        // Render indicator
256        if has_brackets {
257            let mut left_cell = Cell::new(left_bracket);
258            left_cell.fg = Some(label_fg);
259            ctx.buffer.set(current_x, y, left_cell);
260            current_x += 1;
261
262            let indicator = if is_selected {
263                selected_char
264            } else {
265                unselected_char
266            };
267            let mut ind_cell = Cell::new(indicator);
268            ind_cell.fg = Some(indicator_fg);
269            ctx.buffer.set(current_x, y, ind_cell);
270            current_x += 1;
271
272            let mut right_cell = Cell::new(right_bracket);
273            right_cell.fg = Some(label_fg);
274            ctx.buffer.set(current_x, y, right_cell);
275            current_x += 1;
276        } else {
277            let indicator = if is_selected {
278                selected_char
279            } else {
280                unselected_char
281            };
282            let mut ind_cell = Cell::new(indicator);
283            ind_cell.fg = Some(indicator_fg);
284            ctx.buffer.set(current_x, y, ind_cell);
285            current_x += 1;
286        }
287
288        // Space before label
289        ctx.buffer.set(current_x, y, Cell::new(' '));
290        current_x += 1;
291
292        // Render label
293        if let Some(option) = self.options.get(index) {
294            for ch in option.chars() {
295                if current_x >= area.x + area.width {
296                    break;
297                }
298                let mut cell = Cell::new(ch);
299                cell.fg = Some(label_fg);
300                if is_selected && self.focused && !self.disabled {
301                    cell.modifier = crate::render::Modifier::BOLD;
302                }
303                ctx.buffer.set(current_x, y, cell);
304                current_x += 1;
305            }
306        }
307
308        current_x - x
309    }
310}
311
312impl Default for RadioGroup {
313    fn default() -> Self {
314        Self::new(Vec::<String>::new())
315    }
316}
317
318impl View for RadioGroup {
319    crate::impl_view_meta!("RadioGroup");
320
321    fn render(&self, ctx: &mut RenderContext) {
322        let area = ctx.area;
323        if area.width == 0 || area.height == 0 || self.options.is_empty() {
324            return;
325        }
326
327        // Render focus indicator for the group
328        let start_x = if self.focused && !self.disabled {
329            let mut arrow = Cell::new('>');
330            arrow.fg = Some(Color::CYAN);
331            ctx.buffer.set(area.x, area.y, arrow);
332            area.x + 2
333        } else {
334            area.x
335        };
336
337        match self.layout {
338            RadioLayout::Vertical => {
339                let mut y = area.y;
340                for (i, _) in self.options.iter().enumerate() {
341                    if y >= area.y + area.height {
342                        break;
343                    }
344                    self.render_option(ctx, i, start_x, y);
345                    y += 1 + self.gap;
346                }
347            }
348            RadioLayout::Horizontal => {
349                let mut x = start_x;
350                for (i, _option) in self.options.iter().enumerate() {
351                    if x >= area.x + area.width {
352                        break;
353                    }
354                    let width = self.render_option(ctx, i, x, area.y);
355                    x += width + 2 + self.gap; // 2 for spacing between options
356                }
357            }
358        }
359    }
360}
361
362impl_styled_view!(RadioGroup);
363impl_props_builders!(RadioGroup);
364
365/// Create a radio group
366pub fn radio_group<I, S>(options: I) -> RadioGroup
367where
368    I: IntoIterator<Item = S>,
369    S: Into<String>,
370{
371    RadioGroup::new(options)
372}
373
374// Most tests moved to tests/widget_tests.rs
375// Tests below access private fields and must stay inline
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_radio_group_new() {
383        let rg = RadioGroup::new(vec!["Option 1", "Option 2", "Option 3"]);
384        assert_eq!(rg.options.len(), 3);
385        assert_eq!(rg.selected_index(), 0);
386        assert!(!rg.focused);
387        assert!(!rg.disabled);
388    }
389
390    #[test]
391    fn test_radio_group_builder() {
392        let rg = RadioGroup::new(vec!["A", "B", "C"])
393            .selected(1)
394            .focused(true)
395            .disabled(false)
396            .style(RadioStyle::Unicode)
397            .layout(RadioLayout::Horizontal)
398            .gap(2);
399
400        assert_eq!(rg.selected_index(), 1);
401        assert!(rg.focused);
402        assert!(!rg.disabled);
403        assert_eq!(rg.style, RadioStyle::Unicode);
404        assert_eq!(rg.layout, RadioLayout::Horizontal);
405        assert_eq!(rg.gap, 2);
406    }
407
408    #[test]
409    fn test_radio_styles() {
410        assert_eq!(RadioStyle::Parentheses.chars(), ('●', ' '));
411        assert_eq!(RadioStyle::Unicode.chars(), ('◉', '○'));
412        assert_eq!(RadioStyle::Brackets.chars(), ('*', ' '));
413        assert_eq!(RadioStyle::Diamond.chars(), ('◆', '◇'));
414    }
415
416    #[test]
417    fn test_radio_group_helper() {
418        let rg = radio_group(vec!["X", "Y"]);
419        assert_eq!(rg.options.len(), 2);
420    }
421
422    #[test]
423    fn test_radio_group_custom_colors() {
424        let rg = RadioGroup::new(vec!["A", "B"])
425            .fg(Color::WHITE)
426            .selected_fg(Color::GREEN);
427
428        assert_eq!(rg.fg, Some(Color::WHITE));
429        assert_eq!(rg.selected_fg, Some(Color::GREEN));
430    }
431}