1use std::cell::RefCell;
40use std::rc::Rc;
41
42use teksilo_canvas::{Rect, SizeProposal};
43use teksilo_core::accessibility::AccessNodeBuilder;
44use teksilo_core::binding::BindingLevel;
45use teksilo_core::build_context::BuildContext;
46use teksilo_core::color_prop::{ColorProp, TextStyleProp};
47use teksilo_core::event::{EventResponse, Key, WidgetEvent};
48use teksilo_core::signal::{Prop, Signal};
49use teksilo_core::styles::{
50 RadioStyleConfig, RadioTileStyle, RadioTileStyleConfig, RadioTileVariant, RadioVariant,
51 SharedRadioStyle, SharedRadioTileStyle,
52};
53use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
54use teksilo_core::widget_builder::HandlerSet;
55use teksilo_core::widget_id::WidgetId;
56use teksilo_tokens::{HAlignment, TextRole, TextStyleRole, VAlignment};
57
58use crate::button::InteractionState;
59use crate::primitives::{HStack, Spacer, TextWidget, VStack};
60use crate::styles::{RecipeRadioStyle, RecipeRadioTileStyle};
61use teksilo_i18n::LocalizedString;
62
63const TILE_ROW_GAP: f32 = 10.0;
65const TILE_TITLE_DESC_GAP: f32 = 6.0;
67
68#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)]
71pub enum RadioTileIndicatorSide {
72 #[default]
74 Trailing,
75 Leading,
77}
78
79pub struct RadioTile {
81 value: usize,
82 selected: Signal<usize>,
83 icon: Option<Box<dyn Widget>>,
84 title: Option<LocalizedString>,
85 description: Option<LocalizedString>,
86 body: Option<Box<dyn Widget>>,
87 trailing: Option<LocalizedString>,
90 trailing_slot: Option<Box<dyn Widget>>,
91 compact: bool,
95 title_style: Option<TextStyleProp>,
96 title_color: Option<ColorProp>,
97 description_style: Option<TextStyleProp>,
98 description_color: Option<ColorProp>,
99 enabled: Prop<bool>,
102 variant: RadioTileVariant,
103 show_indicator: bool,
104 indicator_side: RadioTileIndicatorSide,
105 tooltip_text: Option<LocalizedString>,
106 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
107 composite_tooltip_content: Option<Box<dyn Widget>>,
108 tooltip_placement: crate::tooltip::TooltipPlacement,
114 style_override: Option<SharedRadioTileStyle>,
115 grouped: bool,
118 group_focused: Option<Signal<bool>>,
119 group_ids: Option<Rc<RefCell<Vec<WidgetId>>>>,
120 pos_in_set: Option<usize>,
121 size_of_set: Option<usize>,
122 root_child_id: Option<WidgetId>,
123}
124
125impl RadioTile {
126 pub fn new() -> Self {
131 Self {
132 value: 0,
133 selected: Signal::new(0),
134 icon: None,
135 title: None,
136 description: None,
137 body: None,
138 trailing: None,
139 trailing_slot: None,
140 compact: false,
141 title_style: None,
142 title_color: None,
143 description_style: None,
144 description_color: None,
145 enabled: Prop::Static(true),
146 variant: RadioTileVariant::default(),
147 show_indicator: true,
148 indicator_side: RadioTileIndicatorSide::default(),
149 tooltip_text: None,
150 rich_tooltip_source: None,
151 composite_tooltip_content: None,
152 tooltip_placement: crate::tooltip::TooltipPlacement::Below,
153 style_override: None,
154 grouped: false,
155 group_focused: None,
156 group_ids: None,
157 pos_in_set: None,
158 size_of_set: None,
159 root_child_id: None,
160 }
161 }
162
163 pub fn selection(mut self, value: usize, selected: Signal<usize>) -> Self {
166 self.value = value;
167 self.selected = selected;
168 self
169 }
170
171 pub fn icon(mut self, widget: impl Widget + 'static) -> Self {
174 self.icon = Some(Box::new(widget));
175 self
176 }
177
178 pub fn icon_boxed(mut self, widget: Box<dyn Widget>) -> Self {
180 self.icon = Some(widget);
181 self
182 }
183
184 pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
186 self.title = Some(title.into());
187 self
188 }
189
190 pub fn description(mut self, text: impl Into<LocalizedString>) -> Self {
193 self.description = Some(text.into());
194 self
195 }
196
197 pub fn body(mut self, widget: impl Widget + 'static) -> Self {
202 self.body = Some(Box::new(widget));
203 self
204 }
205
206 pub fn body_boxed(mut self, widget: Box<dyn Widget>) -> Self {
208 self.body = Some(widget);
209 self
210 }
211
212 pub fn trailing(mut self, text: impl Into<LocalizedString>) -> Self {
217 self.trailing = Some(text.into());
218 self
219 }
220
221 pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
224 self.trailing_slot = Some(Box::new(widget));
225 self
226 }
227
228 pub fn compact(mut self, compact: bool) -> Self {
233 self.compact = compact;
234 self
235 }
236
237 pub fn title_style(mut self, style: impl Into<TextStyleProp>) -> Self {
239 self.title_style = Some(style.into());
240 self
241 }
242
243 pub fn title_color(mut self, color: impl Into<ColorProp>) -> Self {
245 self.title_color = Some(color.into());
246 self
247 }
248
249 pub fn description_style(mut self, style: impl Into<TextStyleProp>) -> Self {
251 self.description_style = Some(style.into());
252 self
253 }
254
255 pub fn description_color(mut self, color: impl Into<ColorProp>) -> Self {
257 self.description_color = Some(color.into());
258 self
259 }
260
261 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
265 self.enabled = enabled.into();
266 self
267 }
268
269 pub fn variant(mut self, variant: RadioTileVariant) -> Self {
271 self.variant = variant;
272 self
273 }
274
275 pub fn show_indicator(mut self, show: bool) -> Self {
278 self.show_indicator = show;
279 self
280 }
281
282 pub fn indicator_side(mut self, side: RadioTileIndicatorSide) -> Self {
284 self.indicator_side = side;
285 self
286 }
287
288 pub fn style(mut self, style: impl RadioTileStyle) -> Self {
291 self.style_override = Some(Rc::new(style));
292 self
293 }
294
295 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
297 self.tooltip_text = Some(text.into());
298 self.rich_tooltip_source = None;
299 self.composite_tooltip_content = None;
300 self
301 }
302
303 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
305 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
306 self.tooltip_text = None;
307 self.composite_tooltip_content = None;
308 self
309 }
310
311 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
313 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
314 self.tooltip_text = None;
315 self.composite_tooltip_content = None;
316 self
317 }
318
319 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
321 self.composite_tooltip_content = Some(Box::new(content));
322 self.tooltip_text = None;
323 self.rich_tooltip_source = None;
324 self
325 }
326
327 pub(crate) fn set_selection(&mut self, value: usize, selected: Signal<usize>) {
330 self.value = value;
331 self.selected = selected;
332 }
333
334 pub(crate) fn set_grouped(
335 &mut self,
336 group_focused: Signal<bool>,
337 group_ids: Rc<RefCell<Vec<WidgetId>>>,
338 pos: usize,
339 size: usize,
340 ) {
341 self.grouped = true;
342 self.group_focused = Some(group_focused);
343 self.group_ids = Some(group_ids);
344 self.pos_in_set = Some(pos);
345 self.size_of_set = Some(size);
346 }
347
348 pub(crate) fn is_enabled(&self) -> bool {
349 self.enabled.get()
350 }
351
352 pub(crate) fn set_vertical_arrangement(&mut self) {
356 self.compact = true;
357 self.indicator_side = RadioTileIndicatorSide::Leading;
358 }
359
360 pub(crate) fn set_tooltip_placement(&mut self, placement: crate::tooltip::TooltipPlacement) {
364 self.tooltip_placement = placement;
365 }
366
367 pub(crate) fn set_style_if_unset(&mut self, style: SharedRadioTileStyle) {
370 if self.style_override.is_none() {
371 self.style_override = Some(style);
372 }
373 }
374
375 fn is_selected(&self) -> bool {
376 self.selected.get() == self.value
377 }
378}
379
380impl Default for RadioTile {
381 fn default() -> Self {
382 Self::new()
383 }
384}
385
386impl std::fmt::Debug for RadioTile {
387 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388 f.debug_struct("RadioTile")
389 .field("value", &self.value)
390 .field("title", &self.title)
391 .field("grouped", &self.grouped)
392 .finish()
393 }
394}
395
396impl Widget for RadioTile {
397 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
398 let selected = self.selected.clone();
399 let value = self.value;
400 let variant = self.variant;
401 let self_id = ctx.self_id();
402
403 ctx.enabled_when(self_id, self.enabled.clone());
404 let effective_enabled = ctx.effective_enabled_signal(self_id);
405
406 {
410 let registry = ctx.binding_registry();
411 self.selected
412 .bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
413 }
414
415 let interaction = ctx.signal(InteractionState::Idle);
416
417 let is_selected = selected.map(move |s| *s == value);
418 let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
419 let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
420 let is_disabled = effective_enabled.map(|on| !*on);
421 let is_focused = if let Some(gf) = &self.group_focused {
424 gf.clone()
425 } else {
426 interaction.map(|s| matches!(s, InteractionState::Focused))
427 };
428 let is_focus_visible = ctx.focus_visible();
429 let is_window_active = ctx.window_active_signal();
430
431 let indicator_id = if self.show_indicator {
435 let radio_style: SharedRadioStyle = ctx
436 .theme()
437 .style_slots
438 .radio
439 .clone()
440 .unwrap_or_else(|| Rc::new(RecipeRadioStyle::default()));
441 let radio_cfg = RadioStyleConfig {
442 is_selected: is_selected.clone(),
443 is_hovered: is_hovered.clone(),
444 is_pressed: is_pressed.clone(),
445 is_focused: Signal::new(false),
446 is_disabled: is_disabled.clone(),
447 variant: RadioVariant::Circle,
448 };
449 Some(radio_style.make_body(&radio_cfg, ctx))
450 } else {
451 None
452 };
453
454 let mut top_row = HStack::new()
457 .spacing(TILE_ROW_GAP)
458 .alignment(VAlignment::Center);
459
460 if self.indicator_side == RadioTileIndicatorSide::Leading
461 && let Some(id) = indicator_id
462 {
463 top_row = top_row.add_child(id);
464 }
465 if let Some(icon) = self.icon.take() {
466 let icon_id = ctx.add_boxed(icon);
467 top_row = top_row.add_child(icon_id);
468 }
469 if let Some(title) = &self.title {
470 let title_widget = TextWidget::new(title.clone())
471 .style(
472 self.title_style
473 .clone()
474 .unwrap_or(TextStyleProp::Role(TextStyleRole::BodyBold)),
475 )
476 .color(
477 self.title_color
478 .clone()
479 .unwrap_or(ColorProp::TextRole(TextRole::Primary)),
480 )
481 .single_line()
482 .a11y_hidden();
483 let title_id = ctx.add(title_widget);
484 top_row = top_row.add_child(title_id);
485 }
486 top_row = top_row.add_child(ctx.add(Spacer::new()));
487 if let Some(slot) = self.trailing_slot.take() {
490 top_row = top_row.add_child(ctx.add_boxed(slot));
491 } else if let Some(trailing) = &self.trailing {
492 let trailing_color = is_selected.map(|s| {
493 if *s {
494 TextRole::Accent
495 } else {
496 TextRole::Secondary
497 }
498 });
499 let trailing_widget = TextWidget::new(trailing.clone())
500 .style(TextStyleProp::Role(TextStyleRole::Small))
501 .color(trailing_color)
502 .single_line()
503 .a11y_hidden();
504 top_row = top_row.add_child(ctx.add(trailing_widget));
505 }
506 if self.indicator_side == RadioTileIndicatorSide::Trailing
507 && let Some(id) = indicator_id
508 {
509 top_row = top_row.add_child(id);
510 }
511 let top_row_id = ctx.add(top_row);
512
513 let mut content_col = VStack::new()
515 .spacing(TILE_TITLE_DESC_GAP)
516 .alignment(HAlignment::Leading)
517 .add_child(top_row_id);
518
519 if !self.compact {
520 if let Some(body) = self.body.take() {
521 let body_id = ctx.add_boxed(body);
522 content_col = content_col.add_child(body_id);
523 } else if let Some(description) = &self.description {
524 let desc_widget = TextWidget::new(description.clone())
525 .style(
526 self.description_style
527 .clone()
528 .unwrap_or(TextStyleProp::Role(TextStyleRole::Small)),
529 )
530 .color(
531 self.description_color
532 .clone()
533 .unwrap_or(ColorProp::TextRole(TextRole::Secondary)),
534 )
535 .a11y_hidden();
536 let desc_id = ctx.add(desc_widget);
537 content_col = content_col.add_child(desc_id);
538 }
539 }
540 let content_id = ctx.add(content_col);
541
542 let style: SharedRadioTileStyle = self
544 .style_override
545 .clone()
546 .or_else(|| ctx.theme().style_slots.radio_tile.clone())
547 .unwrap_or_else(|| Rc::new(RecipeRadioTileStyle::default()));
548 let cfg = RadioTileStyleConfig {
549 content: content_id,
550 is_selected: is_selected.clone(),
551 is_hovered: is_hovered.clone(),
552 is_pressed: is_pressed.clone(),
553 is_focused,
554 is_focus_visible,
555 is_disabled,
556 is_window_active,
557 variant,
558 is_compact: self.compact,
559 };
560 let root_id = style.make_body(&cfg, ctx);
561
562 let tip_placement = self.tooltip_placement;
566 if let Some(content) = self.composite_tooltip_content.take() {
567 let delay = ctx.theme().motion.tooltip_delay_heavy;
568 crate::tooltip::attach_composite_tooltip_boxed_with_placement(
569 ctx,
570 root_id,
571 content,
572 delay,
573 tip_placement,
574 );
575 } else if let Some(source) = self.rich_tooltip_source.take() {
576 let delay = ctx.theme().motion.tooltip_delay;
577 crate::tooltip::attach_rich_tooltip_source_with_placement(
578 ctx,
579 root_id,
580 source,
581 delay,
582 tip_placement,
583 );
584 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
585 let delay = ctx.theme().motion.tooltip_delay;
586 crate::tooltip::attach_plain_tooltip_with_placement(
587 ctx,
588 root_id,
589 tooltip_text,
590 delay,
591 tip_placement,
592 );
593 }
594
595 self.root_child_id = Some(root_id);
596
597 let sel_tap = self.selected.clone();
600 let sel_access = self.selected.clone();
601 let int_tap = interaction.clone();
602 let int_hover = interaction.clone();
603
604 let mut handler_set = HandlerSet::new()
605 .on_tap(move |_pos, _ctx: &mut EventContext| {
606 sel_tap.set(value);
607 int_tap.set(InteractionState::Hovered);
608 })
609 .on_hover(move |entered: bool, _ctx: &mut EventContext| {
610 if entered {
611 int_hover.set(InteractionState::Hovered);
612 } else {
613 int_hover.set(InteractionState::Idle);
614 }
615 })
616 .on_access_action(
617 move |action: teksilo_core::accesskit::Action, _ctx: &mut EventContext| {
618 if action == teksilo_core::accesskit::Action::Click {
619 sel_access.set(value);
620 EventResponse::Handled
621 } else {
622 EventResponse::Ignored
623 }
624 },
625 )
626 .cursor(CursorIcon::Pointer);
627
628 if !self.grouped {
629 let sel_key = self.selected.clone();
630 let int_key = interaction.clone();
631 let int_focus = interaction.clone();
632 handler_set = handler_set
633 .focusable(true)
634 .on_key(
635 move |event: &WidgetEvent, _ctx: &mut EventContext| match event {
636 WidgetEvent::KeyDown {
637 key: Key::Space, ..
638 } => {
639 int_key.set(InteractionState::Pressed);
640 EventResponse::Handled
641 }
642 WidgetEvent::KeyUp {
643 key: Key::Space, ..
644 } => {
645 if int_key.get() != InteractionState::Pressed {
647 return EventResponse::Ignored;
648 }
649 sel_key.set(value);
650 int_key.set(InteractionState::Focused);
651 EventResponse::Handled
652 }
653 _ => EventResponse::Ignored,
654 },
655 )
656 .on_focus(move |gained: bool, _ctx: &mut EventContext| {
657 if gained {
658 if int_focus.get() == InteractionState::Idle {
659 int_focus.set(InteractionState::Focused);
660 }
661 } else {
662 int_focus.set(InteractionState::Idle);
663 }
664 });
665 }
666
667 ctx.apply_self_handlers(handler_set);
668
669 vec![root_id]
670 }
671
672 fn layout_response(
673 &self,
674 proposal: SizeProposal,
675 ctx: &LayoutContext,
676 ) -> teksilo_core::widget::LayoutResponse {
677 if let Some(root) = self.root_child_id
678 && let Some(size) = ctx.child_size(root, proposal)
679 {
680 return size.into();
681 }
682 proposal.resolve(0.0, 0.0).into()
683 }
684
685 fn place_children(
686 &self,
687 bounds: Rect,
688 _proposal: SizeProposal,
689 children: &mut [WidgetPlacement],
690 _ctx: &LayoutContext,
691 ) {
692 for child in children.iter_mut() {
693 child.origin = bounds.origin();
694 child.size = bounds.size();
695 }
696 }
697
698 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
699 builder.set_role(teksilo_core::accesskit::Role::RadioButton);
700 if let Some(ref title) = self.title {
701 builder.set_name(title.resolve_now());
702 }
703 if let Some(ref description) = self.description {
704 builder.set_description(description.resolve_now());
705 } else if let Some(ref trailing) = self.trailing {
706 builder.set_description(trailing.resolve_now());
709 }
710 builder.set_toggled(self.is_selected());
712 if let Some(pos) = self.pos_in_set {
714 builder.set_position_in_set(pos);
715 }
716 if let Some(size) = self.size_of_set {
717 builder.set_size_of_set(size);
718 }
719 if let Some(group_ids) = &self.group_ids {
722 for &id in group_ids.borrow().iter() {
723 builder.push_to_radio_group(teksilo_core::accessibility::widget_id_to_node_id(id));
724 }
725 }
726 builder.add_action(teksilo_core::accesskit::Action::Click);
727 if !self.grouped {
730 builder.add_action(teksilo_core::accesskit::Action::Focus);
731 }
732 }
733
734 fn children(&self) -> Vec<WidgetId> {
735 self.root_child_id.into_iter().collect()
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use teksilo_core::event::Modifiers;
743 use teksilo_core::widget_tree::WidgetTree;
744 use teksilo_i18n::lit;
745 use teksilo_tokens::Color;
746
747 #[test]
748 fn standalone_tap_and_space_select() {
749 let selected = Signal::new(0_usize);
750 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
751 let t0 = tree.add(
752 RadioTile::new()
753 .selection(0, selected.clone())
754 .title(lit!("A")),
755 );
756 let t1 = tree.add(
757 RadioTile::new()
758 .selection(1, selected.clone())
759 .title(lit!("B")),
760 );
761 let _root = tree.add(crate::primitives::VStack::new().add_child(t0).add_child(t1));
762 tree.layout(SizeProposal::exact(300.0, 300.0));
763
764 assert_eq!(selected.get(), 0);
765 tree.click(t1);
766 assert_eq!(selected.get(), 1);
767
768 tree.focus(t0);
770 tree.press_key(Key::Space, Modifiers::NONE);
771 assert_eq!(selected.get(), 0);
772 }
773
774 #[test]
775 fn accessibility_role_and_toggled() {
776 let selected = Signal::new(1_usize);
777 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
778 let t0 = tree.add(
779 RadioTile::new()
780 .selection(0, selected.clone())
781 .title(lit!("A"))
782 .description(lit!("first choice")),
783 );
784 tree.layout(SizeProposal::exact(300.0, 200.0));
785 let info = tree.accessibility_node(t0);
786 assert_eq!(info.role(), teksilo_core::accesskit::Role::RadioButton);
787 assert_eq!(info.name(), Some("A"));
788 assert!(!info.is_toggled());
789 }
790
791 #[test]
792 fn compact_tile_omits_description_and_is_shorter() {
793 use crate::primitives::{FixedSize, VStack};
794 let long = "a long description that would wrap across several lines inside the tile body";
795 let selected = Signal::new(0_usize);
796 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
797 let compact = tree.add(
798 FixedSize::new().width(300.0).child(
799 RadioTile::new()
800 .selection(0, selected.clone())
801 .title(lit!("A"))
802 .description(lit!(long))
803 .compact(true),
804 ),
805 );
806 let card = tree.add(
807 FixedSize::new().width(300.0).child(
808 RadioTile::new()
809 .selection(0, selected.clone())
810 .title(lit!("B"))
811 .description(lit!(long)),
812 ),
813 );
814 let _root = tree.add(VStack::new().add_child(compact).add_child(card));
815 tree.layout(SizeProposal::exact(320.0, 600.0));
816 let a = tree.find_by_label("A").unwrap();
817 let b = tree.find_by_label("B").unwrap();
818 assert!(
819 tree.bounds(a).height < tree.bounds(b).height,
820 "compact tile drops the wrapping description row, so it is shorter"
821 );
822 }
823
824 #[derive(Debug)]
826 struct SentinelTile(Color);
827 impl RadioTileStyle for SentinelTile {
828 fn make_body(&self, cfg: &RadioTileStyleConfig, ctx: &mut BuildContext) -> WidgetId {
829 let rect = ctx.add(crate::primitives::RectWidget::new().background(self.0));
830 ctx.add(
831 crate::primitives::ZStack::new()
832 .add_child(rect)
833 .add_child(cfg.content),
834 )
835 }
836 }
837
838 fn renders_color(tree: &mut WidgetTree, color: Color) -> bool {
839 tree.layout(SizeProposal::exact(200.0, 100.0));
840 let frame = tree.render();
841 frame.shapes.iter().any(|s| s.color == color.to_array())
842 }
843
844 #[test]
845 fn theme_slot_supplies_style_when_no_override() {
846 let mut theme = teksilo_core::presets::intui::light();
847 theme.style_slots.radio_tile =
848 Some(Rc::new(SentinelTile(Color::from_rgba(1.0, 0.0, 1.0, 1.0))));
849 let selected = Signal::new(0_usize);
850 let mut tree = WidgetTree::new().with_theme(theme);
851 tree.add(RadioTile::new().selection(0, selected).title(lit!("X")));
852 assert!(
853 renders_color(&mut tree, Color::from_rgba(1.0, 0.0, 1.0, 1.0)),
854 "theme slot style should paint the sentinel fill"
855 );
856 }
857
858 #[test]
859 fn per_call_style_override_wins_over_theme_slot() {
860 let mut theme = teksilo_core::presets::intui::light();
861 theme.style_slots.radio_tile =
862 Some(Rc::new(SentinelTile(Color::from_rgba(1.0, 0.0, 1.0, 1.0))));
863 let per_call = Color::from_rgba(0.0, 1.0, 0.0, 1.0);
864 let selected = Signal::new(0_usize);
865 let mut tree = WidgetTree::new().with_theme(theme);
866 tree.add(
867 RadioTile::new()
868 .selection(0, selected)
869 .title(lit!("X"))
870 .style(SentinelTile(per_call)),
871 );
872 assert!(
873 renders_color(&mut tree, per_call),
874 "per-call .style() should win over the theme slot"
875 );
876 assert!(
877 !renders_color(&mut tree, Color::from_rgba(1.0, 0.0, 1.0, 1.0)),
878 "theme-slot fill must not appear when overridden per-call"
879 );
880 }
881}