1use std::cell::RefCell;
52use std::rc::Rc;
53
54use teksilo_canvas::{Rect, SizeProposal};
55use teksilo_core::accessibility::AccessNodeBuilder;
56use teksilo_core::binding::BindingLevel;
57use teksilo_core::build_context::BuildContext;
58use teksilo_core::color_prop::{ColorProp, TextStyleProp};
59use teksilo_core::event::{EventResponse, Key, WidgetEvent};
60use teksilo_core::signal::{Prop, Signal};
61use teksilo_core::styles::{
62 RadioStyleConfig, RadioTileStyle, RadioTileStyleConfig, RadioTileVariant, RadioVariant,
63 SharedRadioStyle, SharedRadioTileStyle,
64};
65use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
66use teksilo_core::widget_builder::HandlerSet;
67use teksilo_core::widget_id::WidgetId;
68use teksilo_tokens::{HAlignment, InputTokens, TextRole, TextStyleRole, VAlignment};
69
70use crate::button::InteractionState;
71use crate::primitives::{HStack, Spacer, TextWidget, VStack};
72use crate::styles::{RecipeRadioStyle, RecipeRadioTileStyle};
73use teksilo_core::styles::density::spacing;
74use teksilo_i18n::LocalizedString;
75
76const TILE_ROW_GAP: f32 = 10.0;
78
79fn tile_row_gap(tokens: &InputTokens) -> f32 {
82 spacing(TILE_ROW_GAP, tokens)
83}
84const TILE_TITLE_DESC_GAP: f32 = 6.0;
86
87fn tile_title_desc_gap(tokens: &InputTokens) -> f32 {
90 spacing(TILE_TITLE_DESC_GAP, tokens)
91}
92
93#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)]
96pub enum RadioTileIndicatorSide {
97 #[default]
99 Trailing,
100 Leading,
102}
103
104pub struct RadioTile {
106 value: usize,
107 selected: Signal<usize>,
108 icon: Option<Box<dyn Widget>>,
109 title: Option<LocalizedString>,
110 description: Option<LocalizedString>,
111 body: Option<Box<dyn Widget>>,
112 trailing: Option<LocalizedString>,
115 trailing_slot: Option<Box<dyn Widget>>,
116 compact: bool,
120 title_style: Option<TextStyleProp>,
121 title_color: Option<ColorProp>,
122 description_style: Option<TextStyleProp>,
123 description_color: Option<ColorProp>,
124 enabled: Prop<bool>,
127 variant: RadioTileVariant,
128 show_indicator: bool,
129 indicator_side: RadioTileIndicatorSide,
130 tooltip_text: Option<LocalizedString>,
131 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
132 composite_tooltip_content: Option<Box<dyn Widget>>,
133 tooltip_placement: crate::tooltip::TooltipPlacement,
139 style_override: Option<SharedRadioTileStyle>,
140 grouped: bool,
143 group_focused: Option<Signal<bool>>,
144 group_ids: Option<Rc<RefCell<Vec<WidgetId>>>>,
145 pos_in_set: Option<usize>,
146 root_child_id: Option<WidgetId>,
147}
148
149impl RadioTile {
150 pub fn new() -> Self {
155 Self {
156 value: 0,
157 selected: Signal::new(0),
158 icon: None,
159 title: None,
160 description: None,
161 body: None,
162 trailing: None,
163 trailing_slot: None,
164 compact: false,
165 title_style: None,
166 title_color: None,
167 description_style: None,
168 description_color: None,
169 enabled: Prop::Static(true),
170 variant: RadioTileVariant::default(),
171 show_indicator: true,
172 indicator_side: RadioTileIndicatorSide::default(),
173 tooltip_text: None,
174 rich_tooltip_source: None,
175 composite_tooltip_content: None,
176 tooltip_placement: crate::tooltip::TooltipPlacement::Below,
177 style_override: None,
178 grouped: false,
179 group_focused: None,
180 group_ids: None,
181 pos_in_set: None,
182 root_child_id: None,
183 }
184 }
185
186 pub fn selection(mut self, value: usize, selected: Signal<usize>) -> Self {
189 self.value = value;
190 self.selected = selected;
191 self
192 }
193
194 pub fn icon(mut self, widget: impl Widget + 'static) -> Self {
197 self.icon = Some(Box::new(widget));
198 self
199 }
200
201 pub fn icon_boxed(mut self, widget: Box<dyn Widget>) -> Self {
203 self.icon = Some(widget);
204 self
205 }
206
207 pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
209 self.title = Some(title.into());
210 self
211 }
212
213 pub fn description(mut self, text: impl Into<LocalizedString>) -> Self {
216 self.description = Some(text.into());
217 self
218 }
219
220 pub fn body(mut self, widget: impl Widget + 'static) -> Self {
225 self.body = Some(Box::new(widget));
226 self
227 }
228
229 pub fn body_boxed(mut self, widget: Box<dyn Widget>) -> Self {
231 self.body = Some(widget);
232 self
233 }
234
235 pub fn trailing(mut self, text: impl Into<LocalizedString>) -> Self {
240 self.trailing = Some(text.into());
241 self
242 }
243
244 pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
247 self.trailing_slot = Some(Box::new(widget));
248 self
249 }
250
251 pub fn compact(mut self, compact: bool) -> Self {
256 self.compact = compact;
257 self
258 }
259
260 pub fn title_style(mut self, style: impl Into<TextStyleProp>) -> Self {
262 self.title_style = Some(style.into());
263 self
264 }
265
266 pub fn title_color(mut self, color: impl Into<ColorProp>) -> Self {
268 self.title_color = Some(color.into());
269 self
270 }
271
272 pub fn description_style(mut self, style: impl Into<TextStyleProp>) -> Self {
274 self.description_style = Some(style.into());
275 self
276 }
277
278 pub fn description_color(mut self, color: impl Into<ColorProp>) -> Self {
280 self.description_color = Some(color.into());
281 self
282 }
283
284 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
288 self.enabled = enabled.into();
289 self
290 }
291
292 pub fn variant(mut self, variant: RadioTileVariant) -> Self {
294 self.variant = variant;
295 self
296 }
297
298 pub fn show_indicator(mut self, show: bool) -> Self {
301 self.show_indicator = show;
302 self
303 }
304
305 pub fn indicator_side(mut self, side: RadioTileIndicatorSide) -> Self {
307 self.indicator_side = side;
308 self
309 }
310
311 pub fn style(mut self, style: impl RadioTileStyle) -> Self {
314 self.style_override = Some(Rc::new(style));
315 self
316 }
317
318 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
320 self.tooltip_text = Some(text.into());
321 self.rich_tooltip_source = None;
322 self.composite_tooltip_content = None;
323 self
324 }
325
326 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
328 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
329 self.tooltip_text = None;
330 self.composite_tooltip_content = None;
331 self
332 }
333
334 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
336 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
337 self.tooltip_text = None;
338 self.composite_tooltip_content = None;
339 self
340 }
341
342 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
344 self.composite_tooltip_content = Some(Box::new(content));
345 self.tooltip_text = None;
346 self.rich_tooltip_source = None;
347 self
348 }
349
350 pub(crate) fn set_selection(&mut self, value: usize, selected: Signal<usize>) {
353 self.value = value;
354 self.selected = selected;
355 }
356
357 pub(crate) fn set_grouped(
361 &mut self,
362 group_focused: Signal<bool>,
363 group_ids: Rc<RefCell<Vec<WidgetId>>>,
364 pos: usize,
365 ) {
366 self.grouped = true;
367 self.group_focused = Some(group_focused);
368 self.group_ids = Some(group_ids);
369 self.pos_in_set = Some(pos);
370 }
371
372 pub(crate) fn is_enabled(&self) -> bool {
373 self.enabled.get()
374 }
375
376 pub(crate) fn set_vertical_arrangement(&mut self) {
380 self.compact = true;
381 self.indicator_side = RadioTileIndicatorSide::Leading;
382 }
383
384 pub(crate) fn set_tooltip_placement(&mut self, placement: crate::tooltip::TooltipPlacement) {
388 self.tooltip_placement = placement;
389 }
390
391 pub(crate) fn set_style_if_unset(&mut self, style: SharedRadioTileStyle) {
394 if self.style_override.is_none() {
395 self.style_override = Some(style);
396 }
397 }
398
399 fn is_selected(&self) -> bool {
400 self.selected.get() == self.value
401 }
402}
403
404impl Default for RadioTile {
405 fn default() -> Self {
406 Self::new()
407 }
408}
409
410impl std::fmt::Debug for RadioTile {
411 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412 f.debug_struct("RadioTile")
413 .field("value", &self.value)
414 .field("title", &self.title)
415 .field("grouped", &self.grouped)
416 .finish()
417 }
418}
419
420impl Widget for RadioTile {
421 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
422 let selected = self.selected.clone();
423 let value = self.value;
424 let variant = self.variant;
425 let self_id = ctx.self_id();
426
427 ctx.enabled_when(self_id, self.enabled.clone());
428 let effective_enabled = ctx.effective_enabled_signal(self_id);
429
430 {
434 let registry = ctx.binding_registry();
435 self.selected
436 .bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
437 }
438
439 let interaction = ctx.signal(InteractionState::Idle);
440
441 let is_selected = selected.map(move |s| *s == value);
442 let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
443 let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
444 let is_disabled = effective_enabled.map(|on| !*on);
445 let is_focused = if let Some(gf) = &self.group_focused {
448 gf.clone()
449 } else {
450 interaction.map(|s| matches!(s, InteractionState::Focused))
451 };
452 let is_focus_visible = ctx.focus_visible();
453 let is_window_active = ctx.window_active_signal();
454
455 let indicator_id =
459 if self.show_indicator {
460 let radio_style: SharedRadioStyle =
461 ctx.theme().style_slots.radio.clone().unwrap_or_else(|| {
462 Rc::new(RecipeRadioStyle::for_tokens(&ctx.theme().input))
463 });
464 let radio_cfg = RadioStyleConfig {
465 is_selected: is_selected.clone(),
466 is_hovered: is_hovered.clone(),
467 is_pressed: is_pressed.clone(),
468 is_focused: Signal::new(false),
469 is_disabled: is_disabled.clone(),
470 variant: RadioVariant::Circle,
471 };
472 Some(radio_style.make_body(&radio_cfg, ctx))
473 } else {
474 None
475 };
476
477 let mut top_row = HStack::new()
480 .spacing(tile_row_gap(&ctx.theme().input))
481 .alignment(VAlignment::Center);
482
483 if self.indicator_side == RadioTileIndicatorSide::Leading
484 && let Some(id) = indicator_id
485 {
486 top_row = top_row.child(id);
487 }
488 if let Some(icon) = self.icon.take() {
489 let icon_id = ctx.add_boxed(icon);
490 top_row = top_row.child(icon_id);
491 }
492 if let Some(title) = &self.title {
493 let title_widget = TextWidget::new(title.clone())
494 .style(
495 self.title_style
496 .clone()
497 .unwrap_or(TextStyleProp::Role(TextStyleRole::BodyBold)),
498 )
499 .color(
500 self.title_color
501 .clone()
502 .unwrap_or(ColorProp::TextRole(TextRole::Primary)),
503 )
504 .single_line()
505 .a11y_hidden();
506 let title_id = ctx.add(title_widget);
507 top_row = top_row.child(title_id);
508 }
509 top_row = top_row.child(ctx.add(Spacer::new()));
510 if let Some(slot) = self.trailing_slot.take() {
513 top_row = top_row.child(ctx.add_boxed(slot));
514 } else if let Some(trailing) = &self.trailing {
515 let trailing_color = is_selected.map(|s| {
516 if *s {
517 TextRole::Accent
518 } else {
519 TextRole::Secondary
520 }
521 });
522 let trailing_widget = TextWidget::new(trailing.clone())
523 .style(TextStyleProp::Role(TextStyleRole::Small))
524 .color(trailing_color)
525 .single_line()
526 .a11y_hidden();
527 top_row = top_row.child(ctx.add(trailing_widget));
528 }
529 if self.indicator_side == RadioTileIndicatorSide::Trailing
530 && let Some(id) = indicator_id
531 {
532 top_row = top_row.child(id);
533 }
534 let top_row_id = ctx.add(top_row);
535
536 let mut content_col = VStack::new()
538 .spacing(tile_title_desc_gap(&ctx.theme().input))
539 .alignment(HAlignment::Leading)
540 .child(top_row_id);
541
542 if !self.compact {
543 if let Some(body) = self.body.take() {
544 let body_id = ctx.add_boxed(body);
545 content_col = content_col.child(body_id);
546 } else if let Some(description) = &self.description {
547 let desc_widget = TextWidget::new(description.clone())
548 .style(
549 self.description_style
550 .clone()
551 .unwrap_or(TextStyleProp::Role(TextStyleRole::Small)),
552 )
553 .color(
554 self.description_color
555 .clone()
556 .unwrap_or(ColorProp::TextRole(TextRole::Secondary)),
557 )
558 .a11y_hidden();
559 let desc_id = ctx.add(desc_widget);
560 content_col = content_col.child(desc_id);
561 }
562 }
563 let content_id = ctx.add(content_col);
564
565 let style: SharedRadioTileStyle = self
567 .style_override
568 .clone()
569 .or_else(|| ctx.theme().style_slots.radio_tile.clone())
570 .unwrap_or_else(|| Rc::new(RecipeRadioTileStyle::for_tokens(&ctx.theme().input)));
571 let cfg = RadioTileStyleConfig {
572 content: content_id,
573 is_selected: is_selected.clone(),
574 is_hovered: is_hovered.clone(),
575 is_pressed: is_pressed.clone(),
576 is_focused,
577 is_focus_visible,
578 is_disabled,
579 is_window_active,
580 variant,
581 is_compact: self.compact,
582 };
583 let root_id = style.make_body(&cfg, ctx);
584
585 let tip_placement = self.tooltip_placement;
589 if let Some(content) = self.composite_tooltip_content.take() {
590 let delay = ctx.theme().motion.tooltip_delay_heavy;
591 crate::tooltip::attach_composite_tooltip_boxed_with_placement(
592 ctx,
593 root_id,
594 content,
595 delay,
596 tip_placement,
597 );
598 } else if let Some(source) = self.rich_tooltip_source.take() {
599 let delay = ctx.theme().motion.tooltip_delay;
600 crate::tooltip::attach_rich_tooltip_source_with_placement(
601 ctx,
602 root_id,
603 source,
604 delay,
605 tip_placement,
606 );
607 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
608 let delay = ctx.theme().motion.tooltip_delay;
609 crate::tooltip::attach_plain_tooltip_with_placement(
610 ctx,
611 root_id,
612 tooltip_text,
613 delay,
614 tip_placement,
615 );
616 }
617
618 self.root_child_id = Some(root_id);
619
620 let sel_tap = self.selected.clone();
623 let sel_access = self.selected.clone();
624 let int_tap = interaction.clone();
625 let int_hover = interaction.clone();
626 let hovered_cell = Rc::new(std::cell::Cell::new(false));
635 {
636 let hovered = hovered_cell.clone();
637 crate::common::interaction::bind_press_state(
638 ctx,
639 interaction.clone(),
640 InteractionState::Pressed,
641 move || {
642 if hovered.get() {
643 InteractionState::Hovered
644 } else {
645 InteractionState::Idle
646 }
647 },
648 );
649 }
650 let hovered_for_hover = hovered_cell.clone();
651
652 let mut handler_set = HandlerSet::new()
653 .on_tap(move |_pos, ctx: &mut EventContext| {
654 sel_tap.set(value);
655 int_tap.set(if ctx.pointer_kind().hovers() {
659 InteractionState::Hovered
660 } else {
661 InteractionState::Idle
662 });
663 })
664 .on_hover(move |entered: bool, _ctx: &mut EventContext| {
665 hovered_for_hover.set(entered);
669 if entered {
670 int_hover.set(InteractionState::Hovered);
671 } else {
672 int_hover.set(InteractionState::Idle);
673 }
674 })
675 .on_access_action(
676 move |action: teksilo_core::accesskit::Action, _ctx: &mut EventContext| {
677 if action == teksilo_core::accesskit::Action::Click {
678 sel_access.set(value);
679 EventResponse::Handled
680 } else {
681 EventResponse::Ignored
682 }
683 },
684 )
685 .cursor(CursorIcon::Pointer);
686
687 if !self.grouped {
688 let sel_key = self.selected.clone();
689 let int_key = interaction.clone();
690 let int_focus = interaction.clone();
691 handler_set = handler_set
692 .focusable(true)
693 .on_key(
694 move |event: &WidgetEvent, _ctx: &mut EventContext| match event {
695 WidgetEvent::KeyDown {
696 key: Key::Space, ..
697 } => {
698 int_key.set(InteractionState::Pressed);
699 EventResponse::Handled
700 }
701 WidgetEvent::KeyUp {
702 key: Key::Space, ..
703 } => {
704 if int_key.get() != InteractionState::Pressed {
706 return EventResponse::Ignored;
707 }
708 sel_key.set(value);
709 int_key.set(InteractionState::Focused);
710 EventResponse::Handled
711 }
712 _ => EventResponse::Ignored,
713 },
714 )
715 .on_focus(move |gained: bool, _ctx: &mut EventContext| {
716 if gained {
717 if int_focus.get() == InteractionState::Idle {
718 int_focus.set(InteractionState::Focused);
719 }
720 } else {
721 int_focus.set(InteractionState::Idle);
722 }
723 });
724 }
725
726 ctx.apply_self_handlers(handler_set);
727
728 vec![root_id]
729 }
730
731 fn layout_response(
732 &self,
733 proposal: SizeProposal,
734 ctx: &LayoutContext,
735 ) -> teksilo_core::widget::LayoutResponse {
736 if let Some(root) = self.root_child_id
737 && let Some(size) = ctx.child_size(root, proposal)
738 {
739 return size.into();
740 }
741 proposal.resolve(0.0, 0.0).into()
742 }
743
744 fn place_children(
745 &self,
746 bounds: Rect,
747 _proposal: SizeProposal,
748 children: &mut [WidgetPlacement],
749 _ctx: &LayoutContext,
750 ) {
751 for child in children.iter_mut() {
752 child.origin = bounds.origin();
753 child.size = bounds.size();
754 }
755 }
756
757 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
758 builder.set_role(teksilo_core::accesskit::Role::RadioButton);
759 if let Some(ref title) = self.title {
760 builder.set_name(title.resolve_now());
761 }
762 if let Some(ref description) = self.description {
763 builder.set_description(description.resolve_now());
764 } else if let Some(ref trailing) = self.trailing {
765 builder.set_description(trailing.resolve_now());
768 }
769 builder.set_toggled(self.is_selected());
771 if let Some(pos) = self.pos_in_set {
773 builder.set_position_in_set(pos);
774 }
775 if let Some(group_ids) = &self.group_ids {
781 for &id in group_ids.borrow().iter() {
782 builder.push_to_radio_group(teksilo_core::accessibility::widget_id_to_node_id(id));
783 }
784 }
785 builder.add_action(teksilo_core::accesskit::Action::Click);
786 if !self.grouped {
789 builder.add_action(teksilo_core::accesskit::Action::Focus);
790 }
791 }
792
793 fn children(&self) -> Vec<WidgetId> {
794 self.root_child_id.into_iter().collect()
795 }
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801 use teksilo_core::event::Modifiers;
802 use teksilo_core::widget_tree::WidgetTree;
803 use teksilo_i18n::lit;
804 use teksilo_tokens::Color;
805
806 #[test]
807 fn standalone_tap_and_space_select() {
808 let selected = Signal::new(0_usize);
809 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
810 let t0 = tree.add(
811 RadioTile::new()
812 .selection(0, selected.clone())
813 .title(lit!("A")),
814 );
815 let t1 = tree.add(
816 RadioTile::new()
817 .selection(1, selected.clone())
818 .title(lit!("B")),
819 );
820 let _root = tree.add(crate::primitives::VStack::new().child(t0).child(t1));
821 tree.layout(SizeProposal::exact(300.0, 300.0));
822
823 assert_eq!(selected.get(), 0);
824 tree.click(t1);
825 assert_eq!(selected.get(), 1);
826
827 tree.focus(t0);
829 tree.press_key(Key::Space, Modifiers::NONE);
830 assert_eq!(selected.get(), 0);
831 }
832
833 #[test]
834 fn accessibility_role_and_toggled() {
835 let selected = Signal::new(1_usize);
836 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
837 let t0 = tree.add(
838 RadioTile::new()
839 .selection(0, selected.clone())
840 .title(lit!("A"))
841 .description(lit!("first choice")),
842 );
843 tree.layout(SizeProposal::exact(300.0, 200.0));
844 let info = tree.accessibility_node(t0);
845 assert_eq!(info.role(), teksilo_core::accesskit::Role::RadioButton);
846 assert_eq!(info.name(), Some("A"));
847 assert!(!info.is_toggled());
848 }
849
850 #[test]
851 fn compact_tile_omits_description_and_is_shorter() {
852 use crate::primitives::{FixedSize, VStack};
853 let long = "a long description that would wrap across several lines inside the tile body";
854 let selected = Signal::new(0_usize);
855 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
856 let compact = tree.add(
857 FixedSize::new().width(300.0).child(
858 RadioTile::new()
859 .selection(0, selected.clone())
860 .title(lit!("A"))
861 .description(lit!(long))
862 .compact(true),
863 ),
864 );
865 let card = tree.add(
866 FixedSize::new().width(300.0).child(
867 RadioTile::new()
868 .selection(0, selected.clone())
869 .title(lit!("B"))
870 .description(lit!(long)),
871 ),
872 );
873 let _root = tree.add(VStack::new().child(compact).child(card));
874 tree.layout(SizeProposal::exact(320.0, 600.0));
875 let a = tree.find_by_label("A").unwrap();
876 let b = tree.find_by_label("B").unwrap();
877 assert!(
878 tree.bounds(a).height < tree.bounds(b).height,
879 "compact tile drops the wrapping description row, so it is shorter"
880 );
881 }
882
883 #[derive(Debug)]
885 struct SentinelTile(Color);
886 impl RadioTileStyle for SentinelTile {
887 fn make_body(&self, cfg: &RadioTileStyleConfig, ctx: &mut BuildContext) -> WidgetId {
888 let rect = ctx.add(crate::primitives::RectWidget::new().background(self.0));
889 ctx.add(
890 crate::primitives::ZStack::new()
891 .child(rect)
892 .child(cfg.content),
893 )
894 }
895 }
896
897 fn renders_color(tree: &mut WidgetTree, color: Color) -> bool {
898 tree.layout(SizeProposal::exact(200.0, 100.0));
899 let frame = tree.render();
900 frame.shapes.iter().any(|s| s.color == color.to_array())
901 }
902
903 #[test]
904 fn theme_slot_supplies_style_when_no_override() {
905 let mut theme = teksilo_core::presets::intui::light();
906 theme.style_slots.radio_tile =
907 Some(Rc::new(SentinelTile(Color::from_rgba(1.0, 0.0, 1.0, 1.0))));
908 let selected = Signal::new(0_usize);
909 let mut tree = WidgetTree::new().with_theme(theme);
910 tree.add(RadioTile::new().selection(0, selected).title(lit!("X")));
911 assert!(
912 renders_color(&mut tree, Color::from_rgba(1.0, 0.0, 1.0, 1.0)),
913 "theme slot style should paint the sentinel fill"
914 );
915 }
916
917 #[test]
918 fn per_call_style_override_wins_over_theme_slot() {
919 let mut theme = teksilo_core::presets::intui::light();
920 theme.style_slots.radio_tile =
921 Some(Rc::new(SentinelTile(Color::from_rgba(1.0, 0.0, 1.0, 1.0))));
922 let per_call = Color::from_rgba(0.0, 1.0, 0.0, 1.0);
923 let selected = Signal::new(0_usize);
924 let mut tree = WidgetTree::new().with_theme(theme);
925 tree.add(
926 RadioTile::new()
927 .selection(0, selected)
928 .title(lit!("X"))
929 .style(SentinelTile(per_call)),
930 );
931 assert!(
932 renders_color(&mut tree, per_call),
933 "per-call .style() should win over the theme slot"
934 );
935 assert!(
936 !renders_color(&mut tree, Color::from_rgba(1.0, 0.0, 1.0, 1.0)),
937 "theme-slot fill must not appear when overridden per-call"
938 );
939 }
940}