Skip to main content

teksilo_widgets/
radio_group.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! RadioGroup — invisible layout container that groups `RadioButton`s
5//! and wires their accessibility metadata.
6//!
7//! Radios are a fundamentally group-based control: screen readers need
8//! to announce "2 of 3" positional info, which AccessKit models via
9//! `push_to_radio_group([sibling_ids])` on each radio button. Loose
10//! `RadioButton`s scattered in an HStack can't self-assemble this
11//! relation because they have no knowledge of their siblings.
12//!
13//! `RadioGroup` solves this by owning a shared `Rc<RefCell<Vec<WidgetId>>>`
14//! buffer, injecting it into each `RadioButton` child before adding
15//! them to the arena, and populating the buffer with each radio's
16//! `WidgetId` as it's created. `RadioButton::accessibility()` reads
17//! the buffer and emits the `push_to_radio_group` calls.
18//!
19//! The widget is a pure layout wrapper — it delegates actual
20//! rendering to an `HStack` or `VStack` under the hood. Its own
21//! accessibility node carries `Role::RadioGroup` + an optional
22//! accessible name.
23//!
24//! ```ignore
25//! let selected = ctx.signal(0_usize);
26//! RadioGroup::new()
27//!     .label(lit!("Theme"))
28//!     .radio(RadioButton::new(0, selected.clone()).label(lit!("Light")))
29//!     .radio(RadioButton::new(1, selected.clone()).label(lit!("Dark")))
30//!     .radio(RadioButton::new(2, selected.clone()).label(lit!("System")))
31//! ```
32
33use std::cell::RefCell;
34use std::rc::Rc;
35
36use teksilo_canvas::{Rect, SizeProposal};
37use teksilo_core::accessibility::AccessNodeBuilder;
38use teksilo_core::build_context::BuildContext;
39use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
40use teksilo_core::widget_id::WidgetId;
41use teksilo_tokens::Orientation;
42
43use crate::primitives::{HStack, VStack};
44use crate::radio_button::RadioButton;
45use teksilo_i18n::LocalizedString;
46
47enum RadioGroupChild {
48    /// A radio button whose `group_ids` buffer gets injected at build time.
49    Radio(Box<RadioButton>),
50    /// Any other widget — dividers, section labels, spacers. Passed
51    /// straight through to the internal stack without any a11y wiring.
52    Other(Box<dyn Widget>),
53}
54
55/// Invisible layout container that groups `RadioButton`s for
56/// accessibility. Arranges children in an `HStack` or `VStack`
57/// and carries `Role::RadioGroup` on its own a11y node.
58pub struct RadioGroup {
59    pending: Vec<RadioGroupChild>,
60    orientation: Orientation,
61    spacing: f32,
62    label: Option<LocalizedString>,
63    /// Shared buffer of sibling `WidgetId`s, populated during `build()`.
64    /// Each child radio stores this same `Rc` so its `accessibility()`
65    /// impl can publish the group membership.
66    group_ids: Rc<RefCell<Vec<WidgetId>>>,
67    root_child_id: Option<WidgetId>,
68}
69
70impl RadioGroup {
71    /// Create an empty radio group with vertical orientation and 8 dp spacing.
72    pub fn new() -> Self {
73        Self {
74            pending: Vec::new(),
75            orientation: Orientation::Vertical,
76            spacing: 8.0,
77            label: None,
78            group_ids: Rc::new(RefCell::new(Vec::new())),
79            root_child_id: None,
80        }
81    }
82
83    /// Layout orientation. Defaults to `Vertical` — most radio groups
84    /// read top-to-bottom.
85    pub fn orientation(mut self, orientation: Orientation) -> Self {
86        self.orientation = orientation;
87        self
88    }
89
90    /// Gap between children.
91    pub fn spacing(mut self, spacing: f32) -> Self {
92        self.spacing = spacing;
93        self
94    }
95
96    /// Accessible name for the group — e.g. "Theme", "Font family".
97    /// Screen readers announce this before individual radio labels.
98    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
99        let ls: LocalizedString = label.into();
100        self.label = Some(ls);
101        self
102    }
103
104    /// Add a radio button. The group's shared sibling-id buffer is
105    /// injected into the radio at build time so its accessibility
106    /// impl can publish group membership via `push_to_radio_group`.
107    pub fn radio(mut self, button: RadioButton) -> Self {
108        self.pending.push(RadioGroupChild::Radio(Box::new(button)));
109        self
110    }
111
112    /// Add several radio buttons from an iterator, in order.
113    ///
114    /// The loop form of [`radio`](Self::radio), and the usual one: a radio group
115    /// is normally generated from the list of choices it offers. Each button
116    /// gets the group's shared sibling-id buffer exactly as `radio` gives it.
117    pub fn radios(self, buttons: impl IntoIterator<Item = RadioButton>) -> Self {
118        buttons.into_iter().fold(self, Self::radio)
119    }
120
121    /// Add a non-radio child (divider, caption label, etc.). Passed
122    /// straight through to the internal stack without a11y wiring.
123    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
124        self.pending.push(RadioGroupChild::Other(Box::new(widget)));
125        self
126    }
127
128    /// Add several non-radio children from an iterator, in order.
129    ///
130    /// The loop form of [`child`](Self::child). Like `child`, none of these get
131    /// the group's a11y wiring: use [`radios`](Self::radios) for the buttons.
132    pub fn children(self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
133        iter.into_iter().fold(self, Self::child)
134    }
135    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
136    ///
137    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
138    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
139    /// adds no arena node, so nothing is laid out, painted, or published to the
140    /// accessibility tree, and a stack applies no spacing around it.
141    pub fn child_opt(self, widget: Option<impl Widget + 'static>) -> Self {
142        match widget {
143            Some(w) => self.child(w),
144            None => self,
145        }
146    }
147}
148
149impl Default for RadioGroup {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155impl std::fmt::Debug for RadioGroup {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.debug_struct("RadioGroup")
158            .field("orientation", &self.orientation)
159            .field("spacing", &self.spacing)
160            .field("label", &self.label)
161            .field("num_pending", &self.pending.len())
162            .finish()
163    }
164}
165
166impl Widget for RadioGroup {
167    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
168        let pending = std::mem::take(&mut self.pending);
169        // Rebuilds restart with an empty buffer; children added below
170        // repopulate it. (Only matters if the widget ever rebuilds —
171        // which happens on locale change or structural rebinds.)
172        self.group_ids.borrow_mut().clear();
173
174        // Two-pass build: first inject the group buffer into each
175        // radio before it's moved into the arena, then add each
176        // child and collect WidgetIds (recording the radios in the
177        // shared buffer so siblings see each other).
178        let child_ids: Vec<WidgetId> = pending
179            .into_iter()
180            .map(|child| match child {
181                RadioGroupChild::Radio(mut rb) => {
182                    rb.set_group_ids(self.group_ids.clone());
183                    let id = ctx.add(*rb);
184                    self.group_ids.borrow_mut().push(id);
185                    id
186                }
187                RadioGroupChild::Other(w) => ctx.add_boxed(w),
188            })
189            .collect();
190
191        let spacing = self.spacing;
192        let stack_id = match self.orientation {
193            Orientation::Vertical => {
194                let mut stack = VStack::new().spacing(spacing);
195                for id in child_ids {
196                    stack = stack.child(id);
197                }
198                ctx.add(stack)
199            }
200            Orientation::Horizontal => {
201                let mut stack = HStack::new().spacing(spacing);
202                for id in child_ids {
203                    stack = stack.child(id);
204                }
205                ctx.add(stack)
206            }
207        };
208
209        self.root_child_id = Some(stack_id);
210        vec![stack_id]
211    }
212
213    fn layout_response(
214        &self,
215        proposal: SizeProposal,
216        ctx: &LayoutContext,
217    ) -> teksilo_core::widget::LayoutResponse {
218        self.root_child_id
219            .and_then(|id| ctx.child_size(id, proposal))
220            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
221            .into()
222    }
223
224    fn place_children(
225        &self,
226        bounds: Rect,
227        _proposal: SizeProposal,
228        children: &mut [WidgetPlacement],
229        _ctx: &LayoutContext,
230    ) {
231        for child in children.iter_mut() {
232            child.origin = bounds.origin();
233            child.size = bounds.size();
234        }
235    }
236
237    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
238        builder.set_role(teksilo_core::accesskit::Role::RadioGroup);
239        if let Some(ref name) = self.label {
240            builder.set_name(name.resolve_now());
241        }
242    }
243
244    fn children(&self) -> Vec<WidgetId> {
245        self.root_child_id.into_iter().collect()
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use teksilo_core::signal::Signal;
253    use teksilo_core::widget_tree::WidgetTree;
254    use teksilo_i18n::lit;
255
256    #[test]
257    fn group_publishes_radio_group_role_and_name() {
258        let selected = Signal::new(0_usize);
259        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
260        let rg = tree.add(
261            RadioGroup::new()
262                .label(lit!("Theme"))
263                .radio(RadioButton::new(0, selected.clone()).label(lit!("Light")))
264                .radio(RadioButton::new(1, selected.clone()).label(lit!("Dark"))),
265        );
266        tree.layout(SizeProposal::exact(200.0, 200.0));
267        let info = tree.accessibility_node(rg);
268        assert_eq!(info.role(), teksilo_core::accesskit::Role::RadioGroup);
269        assert_eq!(info.name(), Some("Theme"));
270    }
271
272    #[test]
273    fn member_radios_receive_group_buffer() {
274        // Smoke test: after layout, the group_ids buffer on the
275        // RadioGroup should contain exactly the added radios'
276        // WidgetIds. We can't observe the buffer directly but we
277        // can build the group and assert that each radio's
278        // accessibility still reports set_selected correctly and
279        // that the tree has the expected number of RadioButton
280        // nodes beneath the group.
281        let selected = Signal::new(1_usize);
282        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
283        tree.add(
284            RadioGroup::new()
285                .radio(RadioButton::new(0, selected.clone()).label(lit!("A")))
286                .radio(RadioButton::new(1, selected.clone()).label(lit!("B")))
287                .radio(RadioButton::new(2, selected.clone()).label(lit!("C"))),
288        );
289        tree.layout(SizeProposal::exact(200.0, 200.0));
290
291        let a = tree.find_by_label("A").expect("A radio not found");
292        let b = tree.find_by_label("B").expect("B radio not found");
293        let c = tree.find_by_label("C").expect("C radio not found");
294        let info_a = tree.accessibility_node(a);
295        let info_b = tree.accessibility_node(b);
296        let info_c = tree.accessibility_node(c);
297        assert!(!info_a.is_toggled());
298        assert!(info_b.is_toggled());
299        assert!(!info_c.is_toggled());
300    }
301}