revue/widget/input_widgets/
radio.rs1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum RadioStyle {
13 #[default]
15 Parentheses,
16 Unicode,
18 Brackets,
20 Diamond,
22}
23
24impl RadioStyle {
25 fn chars(&self) -> (char, char) {
27 match self {
28 RadioStyle::Parentheses => ('●', ' '),
29 RadioStyle::Unicode => ('◉', '○'),
30 RadioStyle::Brackets => ('*', ' '),
31 RadioStyle::Diamond => ('◆', '◇'),
32 }
33 }
34
35 fn brackets(&self) -> (char, char) {
37 match self {
38 RadioStyle::Parentheses => ('(', ')'),
39 RadioStyle::Brackets => ('[', ']'),
40 _ => (' ', ' '),
41 }
42 }
43
44 fn has_brackets(&self) -> bool {
46 matches!(self, RadioStyle::Parentheses | RadioStyle::Brackets)
47 }
48}
49
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52pub enum RadioLayout {
53 #[default]
55 Vertical,
56 Horizontal,
58}
59
60#[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 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 pub fn selected(mut self, index: usize) -> Self {
100 self.selection.set(index);
101 self
102 }
103
104 pub fn focused(mut self, focused: bool) -> Self {
106 self.focused = focused;
107 self
108 }
109
110 pub fn disabled(mut self, disabled: bool) -> Self {
112 self.disabled = disabled;
113 self
114 }
115
116 pub fn style(mut self, style: RadioStyle) -> Self {
118 self.style = style;
119 self
120 }
121
122 pub fn layout(mut self, layout: RadioLayout) -> Self {
124 self.layout = layout;
125 self
126 }
127
128 pub fn gap(mut self, gap: u16) -> Self {
130 self.gap = gap;
131 self
132 }
133
134 pub fn fg(mut self, color: Color) -> Self {
136 self.fg = Some(color);
137 self
138 }
139
140 pub fn selected_fg(mut self, color: Color) -> Self {
142 self.selected_fg = Some(color);
143 self
144 }
145
146 pub fn selected_index(&self) -> usize {
148 self.selection.index
149 }
150
151 pub fn selected_value(&self) -> Option<&str> {
153 self.options.get(self.selection.index).map(|s| s.as_str())
154 }
155
156 pub fn is_focused(&self) -> bool {
158 self.focused
159 }
160
161 pub fn is_disabled(&self) -> bool {
163 self.disabled
164 }
165
166 pub fn select_next(&mut self) {
168 if !self.disabled {
169 self.selection.next();
170 }
171 }
172
173 pub fn select_prev(&mut self) {
175 if !self.disabled {
176 self.selection.prev();
177 }
178 }
179
180 pub fn set_focused(&mut self, focused: bool) {
182 self.focused = focused;
183 }
184
185 pub fn set_selected(&mut self, index: usize) {
187 self.selection.set(index);
188 }
189
190 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 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 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 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 ctx.buffer.set(current_x, y, Cell::new(' '));
290 current_x += 1;
291
292 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 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; }
357 }
358 }
359 }
360}
361
362impl_styled_view!(RadioGroup);
363impl_props_builders!(RadioGroup);
364
365pub 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#[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}