teksilo_widgets/
radio_group.rs1use 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 Radio(Box<RadioButton>),
50 Other(Box<dyn Widget>),
53}
54
55pub struct RadioGroup {
59 pending: Vec<RadioGroupChild>,
60 orientation: Orientation,
61 spacing: f32,
62 label: Option<LocalizedString>,
63 group_ids: Rc<RefCell<Vec<WidgetId>>>,
67 root_child_id: Option<WidgetId>,
68}
69
70impl RadioGroup {
71 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 pub fn orientation(mut self, orientation: Orientation) -> Self {
86 self.orientation = orientation;
87 self
88 }
89
90 pub fn spacing(mut self, spacing: f32) -> Self {
92 self.spacing = spacing;
93 self
94 }
95
96 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 pub fn radio(mut self, button: RadioButton) -> Self {
108 self.pending.push(RadioGroupChild::Radio(Box::new(button)));
109 self
110 }
111
112 pub fn radios(self, buttons: impl IntoIterator<Item = RadioButton>) -> Self {
118 buttons.into_iter().fold(self, Self::radio)
119 }
120
121 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
124 self.pending.push(RadioGroupChild::Other(Box::new(widget)));
125 self
126 }
127
128 pub fn children(self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
133 iter.into_iter().fold(self, Self::child)
134 }
135 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 self.group_ids.borrow_mut().clear();
173
174 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 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}