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 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 self.group_ids.borrow_mut().clear();
144
145 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 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}