1use std::cell::RefCell;
33use std::rc::Rc;
34
35use teksilo_canvas::{Rect, Size, SizeProposal};
36use teksilo_core::accessibility::AccessNodeBuilder;
37use teksilo_core::build_context::BuildContext;
38use teksilo_core::event::{EventResponse, Key, WidgetEvent};
39use teksilo_core::signal::{Prop, Signal};
40use teksilo_core::styles::{RadioStyleConfig, RadioVariant, SharedRadioStyle};
41use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
42use teksilo_core::widget_builder::HandlerSet;
43use teksilo_core::widget_id::WidgetId;
44use teksilo_tokens::{TextRole, TextStyleRole, VAlignment};
45
46use crate::button::InteractionState;
47use crate::primitives::{HStack, MinSize, TextWidget, VStack};
48use teksilo_i18n::LocalizedString;
49
50pub struct RadioButton {
52 label: Option<LocalizedString>,
53 caption: Option<LocalizedString>,
54 value: usize,
55 selected: Signal<usize>,
56 enabled: Prop<bool>,
59 tooltip_text: Option<LocalizedString>,
60 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
61 composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
62 variant: RadioVariant,
63 style_override: Option<SharedRadioStyle>,
64 root_child_id: Option<WidgetId>,
65 group_ids: Option<Rc<RefCell<Vec<WidgetId>>>>,
72}
73
74impl RadioButton {
75 pub fn new(value: usize, selected: Signal<usize>) -> Self {
77 Self {
78 label: None,
79 caption: None,
80 value,
81 selected,
82 enabled: Prop::Static(true),
83 tooltip_text: None,
84 rich_tooltip_source: None,
85 composite_tooltip_content: None,
86 variant: RadioVariant::default(),
87 style_override: None,
88 root_child_id: None,
89 group_ids: None,
90 }
91 }
92
93 pub(crate) fn set_group_ids(&mut self, ids: Rc<RefCell<Vec<WidgetId>>>) {
98 self.group_ids = Some(ids);
99 }
100
101 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
103 let ls: LocalizedString = label.into();
104 self.label = Some(ls);
105 self
106 }
107
108 pub fn caption(mut self, text: impl Into<LocalizedString>) -> Self {
112 let ls: LocalizedString = text.into();
113 self.caption = Some(ls);
114 self
115 }
116
117 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
121 self.enabled = enabled.into();
122 self
123 }
124
125 pub fn variant(mut self, variant: RadioVariant) -> Self {
128 self.variant = variant;
129 self
130 }
131
132 pub fn style(mut self, style: impl teksilo_core::styles::RadioStyle) -> Self {
135 self.style_override = Some(Rc::new(style));
136 self
137 }
138
139 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
141 self.tooltip_text = Some(text.into());
142 self.rich_tooltip_source = None;
143 self.composite_tooltip_content = None;
144 self
145 }
146
147 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
150 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
151 self.tooltip_text = None;
152 self.composite_tooltip_content = None;
153 self
154 }
155
156 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
158 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
159 self.tooltip_text = None;
160 self.composite_tooltip_content = None;
161 self
162 }
163
164 pub fn composite_tooltip(
167 mut self,
168 content: impl teksilo_core::widget::Widget + 'static,
169 ) -> Self {
170 self.composite_tooltip_content = Some(Box::new(content));
171 self.tooltip_text = None;
172 self.rich_tooltip_source = None;
173 self
174 }
175
176 fn is_selected(&self) -> bool {
177 self.selected.get() == self.value
178 }
179}
180
181impl std::fmt::Debug for RadioButton {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.debug_struct("RadioButton")
184 .field("label", &self.label)
185 .field("caption", &self.caption)
186 .field("value", &self.value)
187 .finish()
188 }
189}
190
191impl Widget for RadioButton {
194 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
195 use crate::styles::recipe_radio_style as radio_dims;
196 let selected = self.selected.clone();
197 let value = self.value;
198 let variant = self.variant;
199 let self_id = ctx.self_id();
200
201 ctx.enabled_when(self_id, self.enabled.clone());
203 let effective_enabled = ctx.effective_enabled_signal(self_id);
204
205 let interaction = ctx.signal(InteractionState::Idle);
206
207 let is_selected = selected.map(move |s| *s == value);
208 let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
209 let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
210 let is_focused = interaction
214 .map(|s| matches!(s, InteractionState::Focused))
215 .and(&ctx.focus_visible());
216 let is_disabled = effective_enabled.map(|on| !*on);
218
219 let style: SharedRadioStyle = self
220 .style_override
221 .clone()
222 .or_else(|| ctx.theme().style_slots.radio.clone())
223 .unwrap_or_else(|| Rc::new(crate::styles::RecipeRadioStyle::default()));
224 let cfg = RadioStyleConfig {
225 is_selected,
226 is_hovered,
227 is_pressed,
228 is_focused,
229 is_disabled,
230 variant,
231 };
232 let body_id = style.make_body(&cfg, ctx);
233
234 let mut row = HStack::new()
235 .spacing(radio_dims::RADIO_LABEL_GAP)
236 .add_child(body_id);
237 if let Some(ref label) = self.label {
238 let label_widget = TextWidget::new(label.clone())
239 .style(TextStyleRole::Body)
240 .color(TextRole::Primary)
241 .single_line()
242 .a11y_hidden();
243 let label_id = ctx.add(label_widget);
244
245 let label_column_id = if let Some(ref caption) = self.caption {
246 let caption_widget = TextWidget::new(caption.clone())
247 .style(TextStyleRole::Small)
248 .color(TextRole::Secondary)
249 .a11y_hidden();
250 let caption_id = ctx.add(caption_widget);
251 ctx.add(
252 VStack::new()
253 .spacing(2.0)
254 .add_child(label_id)
255 .add_child(caption_id),
256 )
257 } else {
258 label_id
259 };
260 row = row.add_child(label_column_id);
261 }
262 if self.caption.is_some() && self.label.is_some() {
265 row = row.alignment(VAlignment::Top);
266 }
267
268 let row_id = ctx.add(row);
269 let root_id = ctx.add(
270 MinSize::new(radio_dims::RADIO_HIT_AREA, radio_dims::RADIO_HIT_AREA).child_id(row_id),
271 );
272
273 if let Some(content) = self.composite_tooltip_content.take() {
274 let delay = ctx.theme().motion.tooltip_delay_heavy;
275 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
276 } else if let Some(source) = self.rich_tooltip_source.take() {
277 let delay = ctx.theme().motion.tooltip_delay;
278 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
279 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
280 let delay = ctx.theme().motion.tooltip_delay;
281 crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
282 }
283
284 self.root_child_id = Some(root_id);
285
286 let sel_tap = self.selected.clone();
288 let sel_key = self.selected.clone();
289 let sel_access = self.selected.clone();
290 let int_tap = interaction.clone();
291 let int_hover = interaction.clone();
292 let int_key = interaction.clone();
293 let int_focus = interaction.clone();
294
295 let handler_set = HandlerSet::new()
298 .on_tap({
299 move |_pos, _ctx: &mut EventContext| {
300 sel_tap.set(value);
301 int_tap.set(InteractionState::Hovered);
302 }
303 })
304 .on_hover({
305 move |entered: bool, _ctx: &mut EventContext| {
306 if entered {
307 int_hover.set(InteractionState::Hovered);
308 } else {
309 int_hover.set(InteractionState::Idle);
310 }
311 }
312 })
313 .on_key({
314 move |event: &WidgetEvent, _ctx: &mut EventContext| -> EventResponse {
315 match event {
316 WidgetEvent::KeyDown {
317 key: Key::Space, ..
318 } => {
319 int_key.set(InteractionState::Pressed);
320 EventResponse::Handled
321 }
322 WidgetEvent::KeyUp {
323 key: Key::Space, ..
324 } => {
325 if int_key.get() != InteractionState::Pressed {
330 return EventResponse::Ignored;
331 }
332 sel_key.set(value);
333 int_key.set(InteractionState::Focused);
334 EventResponse::Handled
335 }
336 _ => EventResponse::Ignored,
337 }
338 }
339 })
340 .on_focus({
341 move |gained: bool, _ctx: &mut EventContext| {
342 if gained {
343 if int_focus.get() == InteractionState::Idle {
344 int_focus.set(InteractionState::Focused);
345 }
346 } else {
347 int_focus.set(InteractionState::Idle);
348 }
349 }
350 })
351 .on_access_action({
352 move |action: teksilo_core::accesskit::Action,
353 _ctx: &mut EventContext|
354 -> EventResponse {
355 if action == teksilo_core::accesskit::Action::Click {
356 sel_access.set(value);
357 EventResponse::Handled
358 } else {
359 EventResponse::Ignored
360 }
361 }
362 })
363 .focusable(true)
364 .cursor(CursorIcon::Pointer);
365
366 ctx.apply_self_handlers(handler_set);
367
368 vec![root_id]
369 }
370
371 fn layout_response(
372 &self,
373 proposal: SizeProposal,
374 ctx: &LayoutContext,
375 ) -> teksilo_core::widget::LayoutResponse {
376 if let Some(root) = self.root_child_id
377 && let Some(size) = ctx.child_size(root, proposal)
378 {
379 return (size).into();
380 }
381 proposal.resolve(0.0, 0.0).into()
382 }
383
384 fn place_children(
385 &self,
386 bounds: Rect,
387 _proposal: SizeProposal,
388 children: &mut [WidgetPlacement],
389 _ctx: &LayoutContext,
390 ) {
391 for child in children.iter_mut() {
392 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
393 child.size = Size::new(bounds.width, bounds.height);
394 }
395 }
396
397 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
398 builder.set_role(teksilo_core::accesskit::Role::RadioButton);
399 if let Some(ref label) = self.label {
400 builder.set_name(label.resolve_now());
401 }
402 if let Some(ref caption) = self.caption {
403 builder.set_description(caption.resolve_now());
404 }
405 builder.set_toggled(self.is_selected());
408 if let Some(group_ids) = &self.group_ids {
412 for &id in group_ids.borrow().iter() {
413 builder.push_to_radio_group(teksilo_core::accessibility::widget_id_to_node_id(id));
414 }
415 }
416 builder.add_action(teksilo_core::accesskit::Action::Click);
418 builder.add_action(teksilo_core::accesskit::Action::Focus);
419 }
420
421 fn children(&self) -> Vec<WidgetId> {
422 self.root_child_id.into_iter().collect()
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use teksilo_core::event::Modifiers;
430 use teksilo_core::widget_tree::WidgetTree;
431 use teksilo_i18n::lit;
432
433 #[test]
434 fn selecting_one_deselects_others() {
435 use crate::primitives::VStack;
436 let selected = Signal::new(0_usize);
437 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
438 let r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
439 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
440 let r2 = tree.add(RadioButton::new(2, selected.clone()).label(lit!("C")));
441 let _root = tree.add(VStack::new().add_child(r0).add_child(r1).add_child(r2));
442 tree.layout(SizeProposal::exact(200.0, 300.0));
443
444 assert_eq!(selected.get(), 0);
445 tree.click(r1);
446 assert_eq!(selected.get(), 1);
447 tree.click(r2);
448 assert_eq!(selected.get(), 2);
449 tree.click(r0);
450 assert_eq!(selected.get(), 0);
451 }
452
453 #[test]
454 fn space_selects() {
455 let selected = Signal::new(0_usize);
456 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
457 let _r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
458 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
459 tree.layout(SizeProposal::exact(200.0, 200.0));
460
461 tree.focus(r1);
462 tree.press_key(Key::Space, Modifiers::NONE);
463 assert_eq!(selected.get(), 1);
464 }
465
466 #[test]
467 fn lone_keyup_does_not_select() {
468 let selected = Signal::new(0_usize);
470 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
471 let _r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
472 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
473 tree.layout(SizeProposal::exact(200.0, 200.0));
474
475 tree.focus(r1);
476 tree.dispatch_event(WidgetEvent::KeyUp {
477 key: Key::Space,
478 modifiers: Modifiers::NONE,
479 });
480 assert_eq!(selected.get(), 0, "a lone KeyUp must not select the radio");
481
482 tree.press_key(Key::Space, Modifiers::NONE);
483 assert_eq!(selected.get(), 1);
484 }
485
486 #[test]
487 fn accessibility() {
488 let selected = Signal::new(1_usize);
489 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
490 let r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
491 let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
492 tree.layout(SizeProposal::exact(200.0, 200.0));
493
494 let info0 = tree.accessibility_node(r0);
495 assert_eq!(info0.role(), teksilo_core::accesskit::Role::RadioButton);
496 assert!(!info0.is_toggled());
497
498 let info1 = tree.accessibility_node(r1);
499 assert!(info1.is_toggled());
500 }
501
502 #[test]
503 fn accessibility_has_actions() {
504 let selected = Signal::new(0_usize);
505 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
506 let r0 = tree.add(RadioButton::new(0, selected).label(lit!("A")));
507 tree.layout(SizeProposal::exact(200.0, 200.0));
508 let info = tree.accessibility_node(r0);
509 assert!(
510 info.actions()
511 .contains(&teksilo_core::accesskit::Action::Click)
512 );
513 }
514}