Skip to main content

tui_lipan/widgets/
radio.rs

1//! Radio widget.
2
3use std::hash::{Hash, Hasher};
4use std::sync::Arc;
5
6use rustc_hash::FxHasher;
7
8use crate::callback::{Callback, KeyHandler};
9use crate::core::element::{Element, IntoElement};
10use crate::core::event::{KeyCode, KeyEvent, MouseEvent};
11use crate::style::{Length, Padding, Style, StyleSlot};
12use crate::widgets::{Checkbox, CheckboxEvent, CheckboxVariant, HStack, VStack};
13
14/// Layout direction for radio groups.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
16pub enum RadioLayout {
17    /// Stack options vertically.
18    #[default]
19    Vertical,
20    /// Stack options horizontally.
21    Horizontal,
22}
23
24/// A radio button group.
25///
26/// Keyboard model (WAI-ARIA radio group / roving tabindex):
27/// - The selected option (or the first option when none is selected) is the
28///   sole tab stop and focus entry.
29/// - Other options stay mouse-activatable but are not focusable.
30/// - Arrow keys move the selection (and focus follows via [`Radio::focus_key`]).
31#[derive(Clone)]
32pub struct Radio {
33    options: Vec<Arc<str>>,
34    selected: Option<usize>,
35    on_change: Option<Callback<usize>>,
36    disabled: bool,
37    gap: u16,
38    layout: RadioLayout,
39    variant: CheckboxVariant,
40    style: Style,
41    hover_style: StyleSlot,
42    focus_style: StyleSlot,
43    checked_style: Style,
44    unchecked_style: Style,
45    label_style: Style,
46    padding: Padding,
47    width: Length,
48    height: Length,
49    disabled_style: Style,
50    focusable: bool,
51    tab_stop: bool,
52    focus_key: Option<Arc<str>>,
53    on_focus: Option<Callback<usize>>,
54    on_blur: Option<Callback<usize>>,
55    on_key: Option<KeyHandler>,
56}
57
58fn derived_focus_key(options: &[Arc<str>]) -> Arc<str> {
59    let mut hasher = FxHasher::default();
60    options.len().hash(&mut hasher);
61    for option in options {
62        option.hash(&mut hasher);
63    }
64    Arc::from(format!("__tui_lipan_radio:{:016x}", hasher.finish()))
65}
66
67impl Radio {
68    /// Create a new radio group.
69    pub fn new(options: impl IntoIterator<Item = impl Into<Arc<str>>>) -> Self {
70        Self {
71            options: options.into_iter().map(Into::into).collect(),
72            selected: None,
73            on_change: None,
74            disabled: false,
75            gap: 0,
76            layout: RadioLayout::Vertical,
77            variant: CheckboxVariant::Circle,
78            style: Style::default(),
79            hover_style: StyleSlot::Inherit,
80            focus_style: StyleSlot::Inherit,
81            checked_style: Style::default(),
82            unchecked_style: Style::default(),
83            label_style: Style::default(),
84            padding: Padding::default(),
85            width: Length::Auto,
86            height: Length::Auto,
87            disabled_style: Style::default(),
88            focusable: true,
89            tab_stop: true,
90            focus_key: None,
91            on_focus: None,
92            on_blur: None,
93            on_key: None,
94        }
95    }
96
97    /// Set selected index.
98    pub fn selected(mut self, selected: Option<usize>) -> Self {
99        self.selected = selected;
100        self
101    }
102
103    /// Set on-change callback.
104    pub fn on_change(mut self, cb: Callback<usize>) -> Self {
105        self.on_change = Some(cb);
106        self
107    }
108
109    /// Set disabled state.
110    pub fn disabled(mut self, disabled: bool) -> Self {
111        self.disabled = disabled;
112        self
113    }
114
115    /// Set gap between options.
116    pub fn gap(mut self, gap: u16) -> Self {
117        self.gap = gap;
118        self
119    }
120
121    /// Set layout direction.
122    pub fn layout(mut self, layout: RadioLayout) -> Self {
123        self.layout = layout;
124        self
125    }
126
127    /// Set checkbox variant.
128    pub fn variant(mut self, variant: CheckboxVariant) -> Self {
129        self.variant = variant;
130        self
131    }
132
133    /// Set base style.
134    pub fn style(mut self, style: Style) -> Self {
135        self.style = style;
136        self
137    }
138
139    /// Set hover style.
140    pub fn hover_style(mut self, style: Style) -> Self {
141        self.hover_style = StyleSlot::Replace(style);
142        self
143    }
144
145    /// Extend the themed hover style with the given style.
146    pub fn extend_hover_style(mut self, style: Style) -> Self {
147        self.hover_style = StyleSlot::Extend(style);
148        self
149    }
150
151    /// Inherit hover style from the active theme.
152    pub fn inherit_hover_style(mut self) -> Self {
153        self.hover_style = StyleSlot::Inherit;
154        self
155    }
156
157    /// Set hover style slot directly for composite forwarding.
158    pub fn hover_style_slot(mut self, slot: StyleSlot) -> Self {
159        self.hover_style = slot;
160        self
161    }
162
163    /// Set focus style.
164    pub fn focus_style(mut self, style: Style) -> Self {
165        self.focus_style = StyleSlot::Replace(style);
166        self
167    }
168
169    /// Extend the themed focus style with the given style.
170    pub fn extend_focus_style(mut self, style: Style) -> Self {
171        self.focus_style = StyleSlot::Extend(style);
172        self
173    }
174
175    /// Inherit focus style from the active theme.
176    pub fn inherit_focus_style(mut self) -> Self {
177        self.focus_style = StyleSlot::Inherit;
178        self
179    }
180
181    /// Set focus style slot directly for composite forwarding.
182    pub fn focus_style_slot(mut self, slot: StyleSlot) -> Self {
183        self.focus_style = slot;
184        self
185    }
186
187    /// Set checked style.
188    pub fn checked_style(mut self, style: Style) -> Self {
189        self.checked_style = style;
190        self
191    }
192
193    /// Set unchecked style.
194    pub fn unchecked_style(mut self, style: Style) -> Self {
195        self.unchecked_style = style;
196        self
197    }
198
199    /// Set label style.
200    pub fn label_style(mut self, style: Style) -> Self {
201        self.label_style = style;
202        self
203    }
204
205    /// Set padding.
206    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
207        self.padding = padding.into();
208        self
209    }
210
211    /// Set width.
212    pub fn width(mut self, width: Length) -> Self {
213        self.width = width;
214        self
215    }
216
217    /// Set height.
218    pub fn height(mut self, height: Length) -> Self {
219        self.height = height;
220        self
221    }
222
223    /// Set disabled style.
224    pub fn disabled_style(mut self, style: Style) -> Self {
225        self.disabled_style = style;
226        self
227    }
228
229    /// Control whether the active option is focusable.
230    pub fn focusable(mut self, focusable: bool) -> Self {
231        self.focusable = focusable;
232        self
233    }
234
235    /// Control whether the active option participates in tab traversal.
236    ///
237    /// Non-active options are never tab stops (roving focus).
238    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
239        self.tab_stop = tab_stop;
240        self
241    }
242
243    /// Key applied to the active option so focus follows arrow-driven selection.
244    ///
245    /// When omitted, a key is derived from the option labels so distinct groups
246    /// in the same tree do not collide. Override when two groups share the same
247    /// labels (or to give the group a stable app-owned key).
248    pub fn focus_key(mut self, key: impl Into<Arc<str>>) -> Self {
249        self.focus_key = Some(key.into());
250        self
251    }
252
253    /// Set the callback fired when the active option gains focus.
254    pub fn on_focus(mut self, cb: Callback<usize>) -> Self {
255        self.on_focus = Some(cb);
256        self
257    }
258
259    /// Set the callback fired when the active option loses focus.
260    pub fn on_blur(mut self, cb: Callback<usize>) -> Self {
261        self.on_blur = Some(cb);
262        self
263    }
264
265    /// Set focused key handler on the active option (runs before built-in arrows).
266    pub fn on_key(mut self, handler: KeyHandler) -> Self {
267        self.on_key = Some(handler);
268        self
269    }
270}
271
272impl From<Radio> for Element {
273    fn from(radio: Radio) -> Self {
274        let len = radio.options.len();
275        let active = radio
276            .selected
277            .filter(|&i| i < len)
278            .or_else(|| (len > 0).then_some(0));
279        let focus_key = radio
280            .focus_key
281            .clone()
282            .unwrap_or_else(|| derived_focus_key(&radio.options));
283
284        let items: Vec<Element> = radio
285            .options
286            .into_iter()
287            .enumerate()
288            .map(|(i, option)| {
289                let is_selected = radio.selected == Some(i);
290                let is_active = active == Some(i);
291                let on_change = radio.on_change.clone();
292
293                let mut checkbox = Checkbox::new(is_selected)
294                    .label(option)
295                    .variant(radio.variant)
296                    .gap(1)
297                    .style(radio.style)
298                    .hover_style_slot(radio.hover_style)
299                    .focus_style_slot(radio.focus_style)
300                    .checked_style(radio.checked_style)
301                    .unchecked_style(radio.unchecked_style)
302                    .label_style(radio.label_style)
303                    .padding(radio.padding)
304                    .width(radio.width)
305                    .height(radio.height)
306                    .disabled(radio.disabled)
307                    .disabled_style(radio.disabled_style)
308                    .focusable(radio.focusable && is_active && !radio.disabled)
309                    .tab_stop(radio.tab_stop && is_active && !radio.disabled);
310
311                if is_active {
312                    if let Some(cb) = radio.on_focus.clone() {
313                        checkbox = checkbox.on_focus(Callback::new(move |_| cb.emit(i)));
314                    }
315                    if let Some(cb) = radio.on_blur.clone() {
316                        checkbox = checkbox.on_blur(Callback::new(move |_| cb.emit(i)));
317                    }
318                }
319
320                if is_active && !radio.disabled {
321                    let change_cb = radio.on_change.clone();
322                    let caller_on_key = radio.on_key.clone();
323                    let layout = radio.layout;
324                    checkbox = checkbox.on_key(KeyHandler::new(move |key: KeyEvent| {
325                        if caller_on_key
326                            .as_ref()
327                            .is_some_and(|handler| handler.handle(key))
328                        {
329                            return true;
330                        }
331                        handle_radio_key(key, i, len, layout, &change_cb)
332                    }));
333                }
334
335                if let Some(cb) = on_change
336                    && !radio.disabled
337                {
338                    let cb_toggle = cb.clone();
339                    checkbox = checkbox.on_toggle(Callback::new(move |ev: CheckboxEvent| {
340                        if ev.state.is_checked() {
341                            cb_toggle.emit(i);
342                        }
343                    }));
344
345                    checkbox = checkbox.on_click(Callback::new(move |_: MouseEvent| {
346                        cb.emit(i);
347                    }));
348                }
349
350                if is_active {
351                    checkbox.key(focus_key.clone())
352                } else {
353                    checkbox.into()
354                }
355            })
356            .collect();
357
358        match radio.layout {
359            RadioLayout::Vertical => {
360                let mut stack = VStack::new().gap(radio.gap);
361                for item in items {
362                    stack = stack.child(item);
363                }
364                stack.into()
365            }
366            RadioLayout::Horizontal => {
367                let mut stack = HStack::new().gap(radio.gap);
368                for item in items {
369                    stack = stack.child(item);
370                }
371                stack.into()
372            }
373        }
374    }
375}
376
377fn handle_radio_key(
378    key: KeyEvent,
379    current: usize,
380    len: usize,
381    layout: RadioLayout,
382    on_change: &Option<Callback<usize>>,
383) -> bool {
384    if len == 0 || key.mods.ctrl || key.mods.alt || key.mods.super_key {
385        return false;
386    }
387
388    let next = match (layout, key.code) {
389        (RadioLayout::Vertical, KeyCode::Up | KeyCode::Left)
390        | (RadioLayout::Horizontal, KeyCode::Left | KeyCode::Up) => {
391            Some(current.checked_sub(1).unwrap_or(len - 1))
392        }
393        (RadioLayout::Vertical, KeyCode::Down | KeyCode::Right)
394        | (RadioLayout::Horizontal, KeyCode::Right | KeyCode::Down) => Some((current + 1) % len),
395        (_, KeyCode::Home) => Some(0),
396        (_, KeyCode::End) => Some(len - 1),
397        _ => None,
398    };
399
400    let Some(next) = next else {
401        return false;
402    };
403
404    if next != current
405        && let Some(cb) = on_change
406    {
407        cb.emit(next);
408    }
409    true
410}