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