Skip to main content

frus_widgets/
responsive.rs

1//! [`Responsive`]: picks a subtree from the current [`SizeClass`].
2//!
3//! `responsive(width).compact(a).medium(b).expanded(c)` selects the variant matching
4//! the width breakpoint, with a **graceful fallback**: when the exact breakpoint is not
5//! supplied, the nearest is used, preferring the smaller one. The resulting widget
6//! **delegates everything** to the chosen variant, as [`Keyed`] does.
7//!
8//! [`Keyed`]: crate::Keyed
9
10use frus_core::{Rect, Scene, SizeClass};
11use frus_layout::Style;
12
13use crate::interaction::{Key, Status};
14use crate::portal::Placement;
15use crate::runtime::Edit;
16use crate::scroll::Axis;
17use crate::theme::Theme;
18use crate::widget::Widget;
19
20/// Selects a subtree from the size class, delegating to whichever is chosen.
21pub struct Responsive<Msg = crate::callback::Callback> {
22    class_rank: u8,
23    inner: Option<Box<dyn Widget<Msg>>>,
24    inner_rank: u8,
25}
26
27/// A breakpoint's distance from the target class: minimising `(gap, is_above)` picks
28/// the nearest, preferring a smaller breakpoint when the gaps are equal.
29fn closeness(rank: u8, class: u8) -> (u8, bool) {
30    (rank.abs_diff(class), rank > class)
31}
32
33impl<Msg> Responsive<Msg> {
34    /// Builds a selector for the class `class`.
35    pub fn new(class: SizeClass) -> Self {
36        Self {
37            class_rank: class.rank(),
38            inner: None,
39            inner_rank: 0,
40        }
41    }
42
43    /// Considers `widget` for the breakpoint of rank `rank`, keeping the best
44    /// candidate seen so far, independently of the order of the calls.
45    fn consider(mut self, rank: u8, widget: impl Widget<Msg> + 'static) -> Self {
46        let better = self.inner.is_none()
47            || closeness(rank, self.class_rank) < closeness(self.inner_rank, self.class_rank);
48        if better {
49            self.inner = Some(Box::new(widget));
50            self.inner_rank = rank;
51        }
52        self
53    }
54
55    /// Variant for the `Compact` breakpoint (< 600 px).
56    pub fn compact(self, widget: impl Widget<Msg> + 'static) -> Self {
57        self.consider(SizeClass::Compact.rank(), widget)
58    }
59
60    /// Variant for the `Medium` breakpoint (600–840 px).
61    pub fn medium(self, widget: impl Widget<Msg> + 'static) -> Self {
62        self.consider(SizeClass::Medium.rank(), widget)
63    }
64
65    /// Variant for the `Expanded` breakpoint (≥ 840 px).
66    pub fn expanded(self, widget: impl Widget<Msg> + 'static) -> Self {
67        self.consider(SizeClass::Expanded.rank(), widget)
68    }
69}
70
71/// A responsive selector for a given width, in logical px.
72///
73/// `responsive(w).compact(a).medium(b).expanded(c)` — see [`Responsive`].
74pub fn responsive<Msg>(width: f32) -> Responsive<Msg> {
75    Responsive::new(SizeClass::from_width(width))
76}
77
78impl<Msg> Widget<Msg> for Responsive<Msg> {
79    fn style(&self) -> Style {
80        self.inner.as_ref().map(|w| w.style()).unwrap_or_default()
81    }
82
83    fn style_themed(&self, theme: &Theme) -> Style {
84        self.inner
85            .as_ref()
86            .map(|w| w.style_themed(theme))
87            .unwrap_or_default()
88    }
89
90    // Both were missing, the first since `ThemeBuilder` existed: the walk asks this node to
91    // compose itself, the default did nothing, and `children` then read straight through to
92    // a deferred subtree nobody had built. An application bar is one, so
93    // `responsive(w).compact(bar)` was enough; the test below fails without these. The
94    // fourth hook this hand-written forwarder has missed.
95    fn build_themed(&self, theme: &Theme) {
96        if let Some(w) = &self.inner {
97            w.build_themed(theme);
98        }
99    }
100
101    fn build_in(
102        &self,
103        id: crate::interaction::WidgetId,
104        runtime: &crate::runtime::Runtime,
105        theme: &Theme,
106    ) {
107        if let Some(w) = &self.inner {
108            w.build_in(id, runtime, theme);
109        }
110    }
111
112    fn expand(
113        &self,
114        id: crate::interaction::WidgetId,
115        runtime: &crate::runtime::Runtime,
116        theme: &Theme,
117    ) {
118        if let Some(w) = &self.inner {
119            w.expand(id, runtime, theme);
120        }
121    }
122
123    fn switches(&self) -> bool {
124        self.inner.as_ref().is_some_and(|w| w.switches())
125    }
126
127    fn system_ui_style(&self) -> Option<crate::SystemUiOverlayStyle> {
128        self.inner.as_ref().and_then(|w| w.system_ui_style())
129    }
130
131    fn debug_name(&self) -> &'static str {
132        // A transparent wrapper: the inspector shows the realised widget.
133        self.inner
134            .as_ref()
135            .map(|w| w.debug_name())
136            .unwrap_or("Responsive")
137    }
138
139    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
140        self.inner.as_ref().and_then(|w| w.semantics())
141    }
142
143    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
144        self.inner.as_ref().map(|w| w.children()).unwrap_or(&[])
145    }
146
147    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
148        if let Some(w) = &self.inner {
149            w.paint(bounds, status, theme, scene);
150        }
151    }
152
153    fn on_click(&self) -> Option<Msg> {
154        self.inner.as_ref().and_then(|w| w.on_click())
155    }
156
157    fn opaque(&self) -> bool {
158        self.inner.as_ref().is_some_and(|w| w.opaque())
159    }
160
161    fn positional_click(&self, local_x: f32, local_y: f32, width: f32, height: f32) -> Option<Msg> {
162        self.inner
163            .as_ref()
164            .and_then(|w| w.positional_click(local_x, local_y, width, height))
165    }
166
167    fn cursor_icon(
168        &self,
169        local_x: f32,
170        local_y: f32,
171        width: f32,
172        height: f32,
173    ) -> Option<crate::interaction::Cursor> {
174        self.inner
175            .as_ref()
176            .and_then(|w| w.cursor_icon(local_x, local_y, width, height))
177    }
178
179    fn key(&self) -> Option<u64> {
180        self.inner.as_ref().and_then(|w| w.key())
181    }
182
183    fn on_edit(&self, edit: &mut Edit, key: &Key) -> Option<Msg> {
184        self.inner.as_ref().and_then(|w| w.on_edit(edit, key))
185    }
186
187    fn cursor_at(
188        &self,
189        local_x: f32,
190        local_y: f32,
191        width: f32,
192        scroll_cursor: usize,
193    ) -> Option<usize> {
194        self.inner
195            .as_ref()
196            .and_then(|w| w.cursor_at(local_x, local_y, width, scroll_cursor))
197    }
198
199    fn text_metrics(&self, width: f32, cursor: usize) -> Option<(f32, f32, f32, f32)> {
200        self.inner
201            .as_ref()
202            .and_then(|w| w.text_metrics(width, cursor))
203    }
204
205    fn text_viewport(&self, rect: frus_core::Rect) -> Option<frus_core::Rect> {
206        self.inner.as_ref().and_then(|w| w.text_viewport(rect))
207    }
208
209    fn caret_vertical(
210        &self,
211        width: f32,
212        cursor: usize,
213        down: bool,
214        page: bool,
215        goal_x: Option<f32>,
216    ) -> Option<(usize, f32)> {
217        self.inner
218            .as_ref()
219            .and_then(|w| w.caret_vertical(width, cursor, down, page, goal_x))
220    }
221
222    fn selected_text(&self, edit: &Edit) -> Option<String> {
223        self.inner.as_ref().and_then(|w| w.selected_text(edit))
224    }
225
226    fn text_value(&self) -> Option<&str> {
227        self.inner.as_ref().and_then(|w| w.text_value())
228    }
229
230    fn word_at(&self, index: usize) -> Option<(usize, usize)> {
231        self.inner.as_ref().and_then(|w| w.word_at(index))
232    }
233
234    fn selection_handles(
235        &self,
236        width: f32,
237        edit: &Edit,
238        scroll_y: f32,
239    ) -> Option<[crate::SelectionHandle; 2]> {
240        self.inner
241            .as_ref()
242            .and_then(|w| w.selection_handles(width, edit, scroll_y))
243    }
244
245    fn autofill_hints(&self) -> &[crate::AutofillHint] {
246        self.inner
247            .as_ref()
248            .map(|w| w.autofill_hints())
249            .unwrap_or(&[])
250    }
251
252    fn autofill_group(&self) -> bool {
253        self.inner.as_ref().is_some_and(|w| w.autofill_group())
254    }
255
256    fn focusable(&self) -> bool {
257        self.inner.as_ref().is_some_and(|w| w.focusable())
258    }
259
260    fn draws_own_focus(&self) -> bool {
261        self.inner.as_ref().is_some_and(|w| w.draws_own_focus())
262    }
263
264    fn stack(&self) -> bool {
265        self.inner.as_ref().is_some_and(|w| w.stack())
266    }
267
268    fn continuous(&self) -> bool {
269        self.inner.as_ref().is_some_and(|w| w.continuous())
270    }
271
272    fn virtual_list(&self, viewport: frus_core::Size) -> Option<crate::list::VirtualList<'_, Msg>> {
273        self.inner.as_ref().and_then(|w| w.virtual_list(viewport))
274    }
275
276    fn page_view(&self) -> Option<crate::pageview::PagedView<'_, Msg>> {
277        self.inner.as_ref().and_then(|w| w.page_view())
278    }
279
280    fn intrinsic(&self) -> Option<(crate::constraints::IntrinsicAxis, Option<f32>)> {
281        self.inner.as_ref().and_then(|w| w.intrinsic())
282    }
283
284    fn overflow_box(&self) -> Option<crate::constraints::Overflow> {
285        self.inner.as_ref().and_then(|w| w.overflow_box())
286    }
287
288    fn hero_tag(&self) -> Option<u64> {
289        self.inner.as_ref().and_then(|w| w.hero_tag())
290    }
291
292    fn drag_payload(&self) -> Option<u64> {
293        self.inner.as_ref().and_then(|w| w.drag_payload())
294    }
295
296    fn drag_needs_long_press(&self) -> bool {
297        self.inner
298            .as_ref()
299            .is_some_and(|w| w.drag_needs_long_press())
300    }
301
302    fn drag_ghost_opacity(&self) -> f32 {
303        self.inner.as_ref().map_or(1.0, |w| w.drag_ghost_opacity())
304    }
305
306    fn on_dropped(&self, accepted: bool) -> Option<Msg> {
307        self.inner.as_ref().and_then(|w| w.on_dropped(accepted))
308    }
309
310    fn drop_zone(&self) -> bool {
311        self.inner.as_ref().is_some_and(|w| w.drop_zone())
312    }
313
314    fn accepts_drag(&self, payload: u64) -> bool {
315        self.inner.as_ref().is_none_or(|w| w.accepts_drag(payload))
316    }
317
318    fn on_drop(&self, payload: u64) -> Option<Msg> {
319        self.inner.as_ref().and_then(|w| w.on_drop(payload))
320    }
321
322    fn on_page_changed(&self, page: usize) -> Option<Msg> {
323        self.inner.as_ref().and_then(|w| w.on_page_changed(page))
324    }
325
326    fn on_scroll(&self, position: crate::ScrollPosition) -> Option<Msg> {
327        self.inner.as_ref().and_then(|w| w.on_scroll(position))
328    }
329
330    fn scroll_grain(&self) -> f32 {
331        self.inner.as_ref().map_or(0.0, |w| w.scroll_grain())
332    }
333
334    fn layout_builder(&self) -> Option<&dyn Fn(frus_core::Size) -> Box<dyn Widget<Msg>>> {
335        self.inner.as_ref().and_then(|w| w.layout_builder())
336    }
337
338    fn scroll_overlay(&self) -> Option<&crate::scrolloverlay::OverlayBuilder<Msg>> {
339        self.inner.as_ref().and_then(|w| w.scroll_overlay())
340    }
341
342    fn draggable(&self) -> bool {
343        self.inner.as_ref().is_some_and(|w| w.draggable())
344    }
345
346    fn on_drag(&self, fraction: f32) -> Option<Msg> {
347        self.inner.as_ref().and_then(|w| w.on_drag(fraction))
348    }
349
350    fn on_drag_start(&self, fraction: f32) -> Option<Msg> {
351        self.inner.as_ref().and_then(|w| w.on_drag_start(fraction))
352    }
353
354    fn on_drag_end(&self, fraction: f32) -> Option<Msg> {
355        self.inner.as_ref().and_then(|w| w.on_drag_end(fraction))
356    }
357
358    fn on_drag_delta(&self, dx: f32) -> Option<Msg> {
359        self.inner.as_ref().and_then(|w| w.on_drag_delta(dx))
360    }
361
362    fn reorder_index(&self) -> Option<usize> {
363        self.inner.as_ref().and_then(|w| w.reorder_index())
364    }
365
366    fn on_reorder(&self, to: usize) -> Option<Msg> {
367        self.inner.as_ref().and_then(|w| w.on_reorder(to))
368    }
369
370    fn announce(&self) -> Option<String> {
371        self.inner.as_ref().and_then(|w| w.announce())
372    }
373
374    fn overlay_traps_focus(&self) -> bool {
375        self.inner.as_ref().is_some_and(|w| w.overlay_traps_focus())
376    }
377
378    fn scroll_content(&self) -> Option<&dyn Widget<Msg>> {
379        self.inner.as_ref().and_then(|w| w.scroll_content())
380    }
381
382    fn scroll_axis(&self) -> Axis {
383        self.inner
384            .as_ref()
385            .map(|w| w.scroll_axis())
386            .unwrap_or(Axis::Vertical)
387    }
388
389    fn scroll_physics(&self) -> Option<crate::physics::ScrollPhysics> {
390        self.inner.as_ref().and_then(|w| w.scroll_physics())
391    }
392
393    fn keep_visible(
394        &self,
395        size: frus_core::Size,
396        theme: &crate::theme::Theme,
397    ) -> Option<crate::ui::KeepVisible> {
398        self.inner
399            .as_ref()
400            .and_then(|w| w.keep_visible(size, theme))
401    }
402
403    fn ime(&self) -> crate::ime::Ime {
404        self.inner.as_ref().map(|w| w.ime()).unwrap_or_default()
405    }
406
407    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
408        self.inner.as_ref().and_then(|w| w.overlay())
409    }
410
411    fn overlay_dismiss(&self) -> Option<Msg> {
412        self.inner.as_ref().and_then(|w| w.overlay_dismiss())
413    }
414
415    fn overlay_scrim(&self, theme: &Theme) -> Option<frus_core::Color> {
416        self.inner.as_ref().and_then(|w| w.overlay_scrim(theme))
417    }
418
419    fn anim_target(&self) -> Option<f32> {
420        self.inner.as_ref().and_then(|w| w.anim_target())
421    }
422
423    fn anim_duration(&self) -> f32 {
424        self.inner
425            .as_ref()
426            .map(|w| w.anim_duration())
427            .unwrap_or(crate::runtime::ANIM_DURATION)
428    }
429
430    fn anim_curve(&self) -> frus_core::Curve {
431        self.inner
432            .as_ref()
433            .map(|w| w.anim_curve())
434            .unwrap_or(frus_core::Curve::Linear)
435    }
436
437    fn opacity_group(&self) -> Option<f32> {
438        self.inner.as_ref().and_then(|w| w.opacity_group())
439    }
440
441    fn anim_color(&self) -> Option<frus_core::Color> {
442        self.inner.as_ref().and_then(|w| w.anim_color())
443    }
444
445    fn anim_size(&self) -> Option<frus_core::Size> {
446        self.inner.as_ref().and_then(|w| w.anim_size())
447    }
448
449    fn anim_radius(&self) -> Option<frus_core::BorderRadius> {
450        self.inner.as_ref().and_then(|w| w.anim_radius())
451    }
452
453    fn anim_padding(&self) -> Option<frus_core::Insets> {
454        self.inner.as_ref().and_then(|w| w.anim_padding())
455    }
456
457    fn anim_transform(&self) -> Option<crate::runtime::TransformValues> {
458        self.inner.as_ref().and_then(|w| w.anim_transform())
459    }
460
461    // The three below were **missing**, and milestone 477 predicted exactly this: a hook
462    // this forwarder does not list is a value the runtime never finds, on a widget that
463    // passes every test it has because nobody put it inside a `Responsive`. An
464    // `AnimatedAlign` behind one jumped instead of sliding, and a `Positioned` behind one
465    // lost its place in the stack entirely.
466    fn anim_offset(&self) -> Option<(f32, f32)> {
467        self.inner.as_ref().and_then(|w| w.anim_offset())
468    }
469
470    fn anim_pins(&self) -> Option<crate::positioned::Positioning> {
471        self.inner.as_ref().and_then(|w| w.anim_pins())
472    }
473
474    fn anim_fractions(&self) -> Option<(Option<f32>, Option<f32>)> {
475        self.inner.as_ref().and_then(|w| w.anim_fractions())
476    }
477    fn anim_text_style(&self) -> Option<frus_core::TextStyle> {
478        self.inner.as_ref().and_then(|w| w.anim_text_style())
479    }
480
481    fn positioned(&self) -> Option<crate::positioned::Positioning> {
482        self.inner.as_ref().and_then(|w| w.positioned())
483    }
484
485    fn alignment_geometry(&self) -> Option<frus_core::AlignmentGeometry> {
486        self.inner.as_ref().and_then(|w| w.alignment_geometry())
487    }
488
489    fn transform_translate(&self) -> Option<(f32, f32)> {
490        self.inner.as_ref().and_then(|w| w.transform_translate())
491    }
492
493    fn transform_scale(&self) -> Option<(f32, f32, frus_core::Alignment)> {
494        self.inner.as_ref().and_then(|w| w.transform_scale())
495    }
496
497    fn transform_rotate(&self) -> Option<(f32, frus_core::Alignment)> {
498        self.inner.as_ref().and_then(|w| w.transform_rotate())
499    }
500
501    fn clip_shape(&self) -> Option<frus_core::ClipShape> {
502        self.inner.as_ref().and_then(|w| w.clip_shape())
503    }
504
505    fn clip_path(&self) -> Option<&frus_core::Path> {
506        self.inner.as_ref().and_then(|w| w.clip_path())
507    }
508
509    fn barrier(&self) -> Option<crate::barrier::ModalBarrier> {
510        self.inner.as_ref().and_then(|w| w.barrier())
511    }
512
513    fn dismissible(&self) -> Option<crate::dismiss::DismissSpec> {
514        self.inner.as_ref().and_then(|w| w.dismissible())
515    }
516
517    fn on_dismissed(&self, direction: crate::dismiss::DismissDirection) -> Option<Msg> {
518        self.inner.as_ref().and_then(|w| w.on_dismissed(direction))
519    }
520
521    fn sheet(&self) -> Option<&crate::sheet::SheetSpec> {
522        self.inner.as_ref().and_then(|w| w.sheet())
523    }
524
525    fn on_sheet_dismissed(&self) -> Option<Msg> {
526        self.inner.as_ref().and_then(|w| w.on_sheet_dismissed())
527    }
528
529    fn refresh(&self) -> Option<crate::refresh::RefreshSpec> {
530        self.inner.as_ref().and_then(|w| w.refresh())
531    }
532
533    fn on_refresh(&self) -> Option<Msg> {
534        self.inner.as_ref().and_then(|w| w.on_refresh())
535    }
536
537    fn interactive(&self) -> Option<(f32, f32)> {
538        self.inner.as_ref().and_then(|w| w.interactive())
539    }
540
541    fn fitted(&self) -> Option<frus_core::BoxFit> {
542        self.inner.as_ref().and_then(|w| w.fitted())
543    }
544
545    fn rotated_quarter_turns(&self) -> Option<i32> {
546        self.inner.as_ref().and_then(|w| w.rotated_quarter_turns())
547    }
548
549    fn navigator(&self) -> Option<(f32, bool)> {
550        self.inner.as_ref().and_then(|w| w.navigator())
551    }
552
553    fn navigator_retained(&self) -> usize {
554        self.inner.as_ref().map_or(0, |w| w.navigator_retained())
555    }
556
557    fn measure(&self, theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
558        self.inner.as_ref().and_then(|w| w.measure(theme))
559    }
560
561    fn measure_key(&self, theme: &Theme) -> Option<u64> {
562        self.inner.as_ref().and_then(|w| w.measure_key(theme))
563    }
564
565    fn on_long_press(&self) -> Option<Msg> {
566        self.inner.as_ref().and_then(|w| w.on_long_press())
567    }
568
569    fn on_key(&self, key: &crate::Key) -> crate::KeyResponse<Msg> {
570        self.inner
571            .as_ref()
572            .map(|w| w.on_key(key))
573            .unwrap_or(crate::KeyResponse::Ignored)
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use crate::Container;
581    use frus_layout::Dimension;
582
583    /// Style width of the chosen variant (each breakpoint gets a distinct
584    /// width so it can be identified).
585    fn chosen_width(r: &Responsive<()>) -> Dimension {
586        Widget::<()>::style(r).width
587    }
588
589    fn tagged(w: f32) -> Responsive<()> {
590        responsive(w)
591            .compact(Container::new().width(100.0))
592            .medium(Container::new().width(200.0))
593            .expanded(Container::new().width(300.0))
594    }
595
596    #[test]
597    fn picks_variant_for_width() {
598        assert_eq!(chosen_width(&tagged(400.0)), Dimension::Length(100.0));
599        assert_eq!(chosen_width(&tagged(700.0)), Dimension::Length(200.0));
600        assert_eq!(chosen_width(&tagged(1200.0)), Dimension::Length(300.0));
601    }
602
603    #[test]
604    fn falls_back_to_nearest_when_missing() {
605        // Only `expanded` is supplied, so it is used at every width.
606        let only_expanded = |w: f32| responsive::<()>(w).expanded(Container::new().width(300.0));
607        assert_eq!(
608            chosen_width(&only_expanded(300.0)),
609            Dimension::Length(300.0)
610        );
611
612        // compact plus expanded at a medium width (rank 1): compact (rank 0, gap 1,
613        // below) wins over expanded (rank 2, gap 1, above).
614        let two = responsive::<()>(700.0)
615            .compact(Container::new().width(100.0))
616            .expanded(Container::new().width(300.0));
617        assert_eq!(chosen_width(&two), Dimension::Length(100.0));
618    }
619
620    /// **A selector must not swallow a place.** A `Responsive` inside a stack answers for
621    /// the variant it chose, and a layer's pins are the first thing the stack asks about:
622    /// a selector that did not forward them turned every pinned layer behind one into an
623    /// ordinary layer filling the stack, silently.
624    ///
625    /// This was missing until milestone 495, and had never been noticed because nothing
626    /// in the framework or the demo had put a `Positioned` behind a `Responsive`. The
627    /// same shape as the transparent wrapper's own rule, and the same failure.
628    #[test]
629    fn a_selector_keeps_the_layers_place_in_the_stack() {
630        let pinned = responsive::<()>(400.0).compact(
631            crate::Positioned::new(Container::new().width(20.0).height(20.0))
632                .top(8.0)
633                .right(12.0),
634        );
635        let spec = Widget::<()>::positioned(&pinned).expect("the pins reached through");
636        assert_eq!((spec.top, spec.right), (Some(8.0), Some(12.0)));
637    }
638
639    /// **And it must not swallow a movement.** The same hole, one hook along: an
640    /// `AnimatedAlign` behind a selector declared its target to nobody, so the runtime
641    /// found nothing to drive and the child jumped between anchors instead of sliding.
642    #[test]
643    fn a_selector_keeps_the_childs_animated_offset() {
644        let sliding = responsive::<()>(400.0).compact(crate::AnimatedAlign::new(
645            frus_core::Alignment::CENTER_RIGHT,
646            0.2,
647            frus_core::Curve::Linear,
648            Container::new().width(20.0).height(20.0),
649        ));
650        assert_eq!(
651            Widget::<()>::anim_offset(&sliding),
652            // The anchor in its own coordinates, where the middle is nought and an edge
653            // is one — not a share of the box.
654            Some((1.0, 0.0)),
655            "the anchor's two fractions reached through"
656        );
657    }
658
659    /// **And it must not skip the build.** The walk asks each node to compose its subtree
660    /// before it reads the children, and a selector reads its children straight through
661    /// from the variant it chose: one that did not pass the question on handed the walk a
662    /// deferred subtree nobody had built.
663    #[test]
664    fn a_selector_builds_a_deferred_variant() {
665        let chosen = responsive::<()>(400.0).compact(crate::ThemeBuilder::new(|_: &Theme| {
666            Container::new()
667                .width(30.0)
668                .height(10.0)
669                .color(frus_core::Color::rgb(1.0, 0.0, 0.0))
670        }));
671        let ui = crate::ui::build_ui(
672            &chosen,
673            frus_core::Size::new(100.0, 100.0),
674            &crate::Runtime::default(),
675            &Theme::default(),
676        );
677        assert!(
678            ui.scene()
679                .primitives()
680                .iter()
681                .any(|p| matches!(p, frus_core::Primitive::Rect { color, .. } if color.r > 0.5)),
682            "the variant was built, and painted"
683        );
684    }
685}