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