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 a non-radio child (divider, caption label, etc.). Passed
113    /// straight through to the internal stack without a11y wiring.
114    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
115        self.pending.push(RadioGroupChild::Other(Box::new(widget)));
116        self
117    }
118}
119
120impl Default for RadioGroup {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl std::fmt::Debug for RadioGroup {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("RadioGroup")
129            .field("orientation", &self.orientation)
130            .field("spacing", &self.spacing)
131            .field("label", &self.label)
132            .field("num_pending", &self.pending.len())
133            .finish()
134    }
135}
136
137impl Widget for RadioGroup {
138    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
139        let pending = std::mem::take(&mut self.pending);
140        // Rebuilds restart with an empty buffer; children added below
141        // repopulate it. (Only matters if the widget ever rebuilds —
142        // which happens on locale change or structural rebinds.)
143        self.group_ids.borrow_mut().clear();
144
145        // Two-pass build: first inject the group buffer into each
146        // radio before it's moved into the arena, then add each
147        // child and collect WidgetIds (recording the radios in the
148        // shared buffer so siblings see each other).
149        let child_ids: Vec<WidgetId> = pending
150            .into_iter()
151            .map(|child| match child {
152                RadioGroupChild::Radio(mut rb) => {
153                    rb.set_group_ids(self.group_ids.clone());
154                    let id = ctx.add(*rb);
155                    self.group_ids.borrow_mut().push(id);
156                    id
157                }
158                RadioGroupChild::Other(w) => ctx.add_boxed(w),
159            })
160            .collect();
161
162        let spacing = self.spacing;
163        let stack_id = match self.orientation {
164            Orientation::Vertical => {
165                let mut stack = VStack::new().spacing(spacing);
166                for id in child_ids {
167                    stack = stack.add_child(id);
168                }
169                ctx.add(stack)
170            }
171            Orientation::Horizontal => {
172                let mut stack = HStack::new().spacing(spacing);
173                for id in child_ids {
174                    stack = stack.add_child(id);
175                }
176                ctx.add(stack)
177            }
178        };
179
180        self.root_child_id = Some(stack_id);
181        vec![stack_id]
182    }
183
184    fn layout_response(
185        &self,
186        proposal: SizeProposal,
187        ctx: &LayoutContext,
188    ) -> teksilo_core::widget::LayoutResponse {
189        self.root_child_id
190            .and_then(|id| ctx.child_size(id, proposal))
191            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
192            .into()
193    }
194
195    fn place_children(
196        &self,
197        bounds: Rect,
198        _proposal: SizeProposal,
199        children: &mut [WidgetPlacement],
200        _ctx: &LayoutContext,
201    ) {
202        for child in children.iter_mut() {
203            child.origin = bounds.origin();
204            child.size = bounds.size();
205        }
206    }
207
208    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
209        builder.set_role(teksilo_core::accesskit::Role::RadioGroup);
210        if let Some(ref name) = self.label {
211            builder.set_name(name.resolve_now());
212        }
213    }
214
215    fn children(&self) -> Vec<WidgetId> {
216        self.root_child_id.into_iter().collect()
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use teksilo_core::signal::Signal;
224    use teksilo_core::widget_tree::WidgetTree;
225    use teksilo_i18n::lit;
226
227    #[test]
228    fn group_publishes_radio_group_role_and_name() {
229        let selected = Signal::new(0_usize);
230        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
231        let rg = tree.add(
232            RadioGroup::new()
233                .label(lit!("Theme"))
234                .radio(RadioButton::new(0, selected.clone()).label(lit!("Light")))
235                .radio(RadioButton::new(1, selected.clone()).label(lit!("Dark"))),
236        );
237        tree.layout(SizeProposal::exact(200.0, 200.0));
238        let info = tree.accessibility_node(rg);
239        assert_eq!(info.role(), teksilo_core::accesskit::Role::RadioGroup);
240        assert_eq!(info.name(), Some("Theme"));
241    }
242
243    #[test]
244    fn member_radios_receive_group_buffer() {
245        // Smoke test: after layout, the group_ids buffer on the
246        // RadioGroup should contain exactly the added radios'
247        // WidgetIds. We can't observe the buffer directly but we
248        // can build the group and assert that each radio's
249        // accessibility still reports set_selected correctly and
250        // that the tree has the expected number of RadioButton
251        // nodes beneath the group.
252        let selected = Signal::new(1_usize);
253        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
254        tree.add(
255            RadioGroup::new()
256                .radio(RadioButton::new(0, selected.clone()).label(lit!("A")))
257                .radio(RadioButton::new(1, selected.clone()).label(lit!("B")))
258                .radio(RadioButton::new(2, selected.clone()).label(lit!("C"))),
259        );
260        tree.layout(SizeProposal::exact(200.0, 200.0));
261
262        let a = tree.find_by_label("A").expect("A radio not found");
263        let b = tree.find_by_label("B").expect("B radio not found");
264        let c = tree.find_by_label("C").expect("C radio not found");
265        let info_a = tree.accessibility_node(a);
266        let info_b = tree.accessibility_node(b);
267        let info_c = tree.accessibility_node(c);
268        assert!(!info_a.is_toggled());
269        assert!(info_b.is_toggled());
270        assert!(!info_c.is_toggled());
271    }
272}