1use std::collections::BTreeSet;
134use std::rc::Rc;
135use std::time::Duration;
136
137use frust::authoring::{Action, Role};
138use frust::authoring::{
139 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CornerRadii, ErasedArgCallback,
140 EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
141 View, Widget, any,
142};
143use frust::{AnimationController, FrameTime, IconData, SpringDesc, Theme, Tween};
144use kurbo::{Affine, BezPath, Point, Size, Vec2};
145use peniko::{Brush, Color};
146
147use crate::card_list::{card_position, card_radii};
148use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
149use crate::press::presses;
150
151pub const EXPANDABLE_OUTER_RADIUS: f64 = 24.0;
156pub const EXPANDABLE_INNER_RADIUS: f64 = 4.0;
159pub const EXPANDABLE_HOVER_RADIUS: f64 = 10.0;
162pub const EXPANDABLE_PRESSED_RADIUS: f64 = 4.0;
165pub const EXPANDABLE_GAP: f64 = 4.0;
168pub const EXPANDABLE_ICON_ROTATION: f64 = std::f64::consts::PI;
172
173const HEADER_PAD_X: f64 = 16.0;
176const HEADER_PAD_TOP: f64 = 14.0;
178const HEADER_PAD_BOTTOM: f64 = 2.0;
180const BODY_PAD_X: f64 = 16.0;
183const BODY_PAD_TOP: f64 = 0.0;
186const BODY_PAD_BOTTOM: f64 = 20.0;
188const CHEVRON_SIZE: f64 = 24.0;
191const CHEVRON_PAD: f64 = 8.0;
194const CHEVRON_SLOT: f64 = CHEVRON_SIZE + CHEVRON_PAD * 2.0;
196
197const FILLED_CONTAINER: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
201const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
204const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
207
208pub const REVEAL_SPRING: SpringDesc = SpringDesc {
216 mass: 1.0,
217 stiffness: 380.0,
218 damping_ratio: 0.8,
219};
220
221const REVEAL_PERIOD: Duration = Duration::from_millis(300);
224
225const REVEAL_LAUNCH_VELOCITY: f64 = 1e-3;
231
232#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
237pub enum ExpandMode {
238 #[default]
241 SingleOpen,
242 Independent,
245}
246
247pub fn is_expanded(expanded: &BTreeSet<usize>, index: usize) -> bool {
249 expanded.contains(&index)
250}
251
252pub fn toggle_expanded(
259 expanded: &BTreeSet<usize>,
260 index: usize,
261 mode: ExpandMode,
262) -> BTreeSet<usize> {
263 if expanded.contains(&index) {
264 let mut next = expanded.clone();
265 next.remove(&index);
266 return next;
267 }
268 let mut next = match mode {
269 ExpandMode::SingleOpen => BTreeSet::new(),
270 ExpandMode::Independent => expanded.clone(),
271 };
272 next.insert(index);
273 next
274}
275
276struct Reveal {
289 value: f64,
291 target: f64,
293 tween: Tween<f64>,
295 driver: AnimationController,
296}
297
298impl Reveal {
299 fn new(initial: f64) -> Self {
304 Self {
305 value: initial,
306 target: initial,
307 tween: Tween::new(initial, initial),
308 driver: AnimationController::new(REVEAL_PERIOD),
309 }
310 }
311
312 fn set_target(&mut self, target: f64) {
314 if self.target == target {
315 return;
316 }
317 self.target = target;
318 self.tween = Tween::new(self.value, target);
319 self.driver = AnimationController::new(REVEAL_PERIOD);
320 self.driver.fling(REVEAL_LAUNCH_VELOCITY, REVEAL_SPRING);
321 }
322
323 fn snap(&mut self) {
325 self.value = self.target;
326 self.tween = Tween::new(self.target, self.target);
327 self.driver.stop();
328 }
329
330 fn advance(&mut self, now: FrameTime) -> bool {
332 let animating = self.driver.advance(now);
333 self.value = self.tween.lerp(self.driver.value_clamped()).clamp(0.0, 1.0);
334 animating
335 }
336}
337
338pub struct ExpandableItem<State: 'static> {
342 header: AnyView<State>,
343 body: AnyView<State>,
344}
345
346pub fn expandable_item<State: 'static, H: View<State>, B: View<State>>(
350 header: H,
351 body: B,
352) -> ExpandableItem<State> {
353 ExpandableItem {
354 header: any(header),
355 body: any(body),
356 }
357}
358
359type OnToggleIndex<State> = Rc<dyn Fn(&mut State, usize)>;
361
362pub struct ExpandableListView<State: 'static> {
364 items: Vec<ExpandableItem<State>>,
365 expanded: BTreeSet<usize>,
366 outer_radius: f64,
367 inner_radius: f64,
368 hover_radius: f64,
369 pressed_radius: f64,
370 expanded_radius: Option<f64>,
371 gap: f64,
372 color: Option<Color>,
373 haptic: HapticSignal,
374 on_toggle: Option<OnToggleIndex<State>>,
375}
376
377pub fn expandable_list<State: 'static>(
382 items: impl IntoIterator<Item = ExpandableItem<State>>,
383) -> ExpandableListView<State> {
384 ExpandableListView {
385 items: items.into_iter().collect(),
386 expanded: BTreeSet::new(),
387 outer_radius: EXPANDABLE_OUTER_RADIUS,
388 inner_radius: EXPANDABLE_INNER_RADIUS,
389 hover_radius: EXPANDABLE_HOVER_RADIUS,
390 pressed_radius: EXPANDABLE_PRESSED_RADIUS,
391 expanded_radius: None,
392 gap: EXPANDABLE_GAP,
393 color: None,
394 haptic: HapticSignal::None,
395 on_toggle: None,
396 }
397}
398
399impl<State: 'static> ExpandableListView<State> {
400 pub fn expanded(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
403 self.expanded = indices.into_iter().collect();
404 self
405 }
406
407 pub fn outer_radius(mut self, radius: f64) -> Self {
410 self.outer_radius = radius;
411 self
412 }
413
414 pub fn inner_radius(mut self, radius: f64) -> Self {
417 self.inner_radius = radius;
418 self
419 }
420
421 pub fn hover_radius(mut self, radius: f64) -> Self {
424 self.hover_radius = radius;
425 self
426 }
427
428 pub fn pressed_radius(mut self, radius: f64) -> Self {
431 self.pressed_radius = radius;
432 self
433 }
434
435 pub fn expanded_radius(mut self, radius: f64) -> Self {
439 self.expanded_radius = Some(radius);
440 self
441 }
442
443 pub fn gap(mut self, gap: f64) -> Self {
446 self.gap = gap;
447 self
448 }
449
450 pub fn color(mut self, color: Color) -> Self {
453 self.color = Some(color);
454 self
455 }
456
457 pub fn haptic(mut self, haptic: HapticSignal) -> Self {
461 self.haptic = haptic;
462 self
463 }
464
465 pub fn on_toggle<F: Fn(&mut State, usize) + 'static>(mut self, on_toggle: F) -> Self {
469 self.on_toggle = Some(Rc::new(on_toggle));
470 self
471 }
472
473 fn flat_views(&self) -> Vec<&AnyView<State>> {
477 self.items
478 .iter()
479 .flat_map(|item| [&item.header, &item.body])
480 .collect()
481 }
482}
483
484#[derive(Clone, Copy, Debug, Default, PartialEq)]
489struct ItemGeometry {
490 y: f64,
492 header: f64,
494 body_full: f64,
496 height: f64,
498}
499
500pub struct ExpandableListWidget {
502 pods: Vec<ChildPod>,
505 expanded: BTreeSet<usize>,
506 reveals: Vec<Reveal>,
507 geometry: Vec<ItemGeometry>,
508 laid_out_reveals: Vec<f64>,
513 outer_radius: f64,
514 inner_radius: f64,
515 hover_radius: f64,
516 pressed_radius: f64,
517 expanded_radius: Option<f64>,
518 gap: f64,
519 color: Option<Color>,
520 haptic: HapticSignal,
521 interactive: bool,
522 width: f64,
524 armed: Option<usize>,
527 active: Option<usize>,
529 state: InteractionState,
530 chevron: Option<(BezPath, f64)>,
532 on_toggle: Option<ErasedArgCallback<usize>>,
533}
534
535fn with_alpha(color: Color, alpha: f32) -> Color {
538 let c = color.components;
539 Color::new([c[0], c[1], c[2], alpha])
540}
541
542fn resolve_container(theme: Option<&Theme>, override_color: Option<Color>) -> Color {
545 if let Some(color) = override_color {
546 return color;
547 }
548 match theme {
549 Some(theme) => theme.scheme().surface_container_highest,
550 None => FILLED_CONTAINER,
551 }
552}
553
554fn resolve_content(theme: Option<&Theme>) -> Color {
556 match theme {
557 Some(theme) => theme.scheme().on_surface,
558 None => ON_SURFACE,
559 }
560}
561
562fn resolve_chevron(theme: Option<&Theme>) -> Color {
564 match theme {
565 Some(theme) => theme.scheme().on_surface_variant,
566 None => ON_SURFACE_VARIANT,
567 }
568}
569
570impl<State: 'static> View<State> for ExpandableListView<State> {
571 type Element = ExpandableListWidget;
572
573 fn build(&self, ctx: &mut BuildCtx<'_>) -> ExpandableListWidget {
574 let pods = self
575 .flat_views()
576 .into_iter()
577 .map(|view| frust::authoring::build_child(view, ctx))
578 .collect();
579 let reveals = (0..self.items.len())
580 .map(|index| {
581 Reveal::new(if self.expanded.contains(&index) {
582 1.0
583 } else {
584 0.0
585 })
586 })
587 .collect();
588 ExpandableListWidget {
589 pods,
590 expanded: self.expanded.clone(),
591 reveals,
592 geometry: vec![ItemGeometry::default(); self.items.len()],
593 laid_out_reveals: vec![0.0; self.items.len()],
594 outer_radius: self.outer_radius,
595 inner_radius: self.inner_radius,
596 hover_radius: self.hover_radius,
597 pressed_radius: self.pressed_radius,
598 expanded_radius: self.expanded_radius,
599 gap: self.gap,
600 color: self.color,
601 haptic: self.haptic,
602 interactive: self.on_toggle.is_some(),
603 width: 0.0,
604 armed: None,
605 active: None,
606 state: InteractionState::new(),
607 chevron: Some(IconData::from(crate::icons::EXPAND_MORE_ROUNDED).resolve()),
608 on_toggle: self
609 .on_toggle
610 .as_ref()
611 .map(frust::authoring::erase_callback_arg),
612 }
613 }
614
615 fn rebuild(
616 &self,
617 prev: &Self,
618 element: &mut ExpandableListWidget,
619 ctx: &mut BuildCtx<'_>,
620 ) -> ChangeFlags {
621 let prev_flat = prev.flat_views();
622 let next_flat = self.flat_views();
623 let mut flags = frust::authoring::rebuild_children(
624 &prev_flat,
625 &next_flat,
626 &mut element.pods,
627 ctx,
628 |view| *view,
629 |_| None,
630 );
631
632 if prev.items.len() != self.items.len() {
633 element.armed = None;
637 element.active = None;
638 element.state.set_pressed(false);
639 element.state.set_hovered(false);
640 element.reveals = (0..self.items.len())
641 .map(|index| {
642 Reveal::new(if self.expanded.contains(&index) {
643 1.0
644 } else {
645 0.0
646 })
647 })
648 .collect();
649 element.geometry = vec![ItemGeometry::default(); self.items.len()];
650 element.laid_out_reveals = vec![0.0; self.items.len()];
651 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
652 } else if element.expanded != self.expanded {
653 for (index, reveal) in element.reveals.iter_mut().enumerate() {
654 reveal.set_target(if self.expanded.contains(&index) {
655 1.0
656 } else {
657 0.0
658 });
659 }
660 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
661 }
662 element.expanded = self.expanded.clone();
663
664 if element.outer_radius != self.outer_radius
665 || element.inner_radius != self.inner_radius
666 || element.hover_radius != self.hover_radius
667 || element.pressed_radius != self.pressed_radius
668 || element.expanded_radius != self.expanded_radius
669 || element.color != self.color
670 {
671 element.outer_radius = self.outer_radius;
672 element.inner_radius = self.inner_radius;
673 element.hover_radius = self.hover_radius;
674 element.pressed_radius = self.pressed_radius;
675 element.expanded_radius = self.expanded_radius;
676 element.color = self.color;
677 flags |= ChangeFlags::PAINT;
678 }
679 if element.gap != self.gap {
680 element.gap = self.gap;
681 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
682 }
683
684 let now_interactive = self.on_toggle.is_some();
685 if element.interactive != now_interactive {
686 element.interactive = now_interactive;
687 if !now_interactive {
688 element.armed = None;
691 element.active = None;
692 element.state.set_pressed(false);
693 element.state.set_hovered(false);
694 }
695 flags |= ChangeFlags::PAINT;
696 }
697 element.haptic = self.haptic;
698 element.on_toggle = self
699 .on_toggle
700 .as_ref()
701 .map(frust::authoring::erase_callback_arg);
702 flags
703 }
704
705 fn teardown(&self, element: &mut ExpandableListWidget, ctx: &mut BuildCtx<'_>) {
706 for (view, pod) in self.flat_views().into_iter().zip(element.pods.iter_mut()) {
707 frust::authoring::teardown_child(view, pod, ctx);
708 }
709 }
710}
711
712impl ExpandableListWidget {
713 fn item_count(&self) -> usize {
715 self.pods.len() / 2
716 }
717
718 fn header_at(&self, pos: Point) -> Option<usize> {
721 if pos.x < 0.0 || pos.x >= self.width {
722 return None;
723 }
724 self.geometry
725 .iter()
726 .position(|g| pos.y >= g.y && pos.y < g.y + g.header)
727 }
728
729 fn radii_at(&self, index: usize) -> CornerRadii {
733 if let Some(radius) = self.expanded_radius
734 && self.expanded.contains(&index)
735 {
736 return CornerRadii::uniform(radius);
737 }
738 let inner = if self.armed == Some(index) && self.state.pressed {
739 self.pressed_radius
740 } else if self.active == Some(index) && self.state.hovered {
741 self.hover_radius
742 } else {
743 self.inner_radius
744 };
745 card_radii(
746 card_position(index, self.item_count()),
747 self.outer_radius,
748 inner,
749 )
750 }
751
752 fn clear_press(&mut self) {
754 self.armed = None;
755 self.state.set_pressed(false);
756 if !self.state.is_active() {
757 self.active = None;
758 }
759 }
760
761 fn chevron_path(&self, index: usize) -> Option<BezPath> {
764 let (path, design) = self.chevron.as_ref()?;
765 if *design <= 0.0 {
766 return None;
767 }
768 let geometry = self.geometry.get(index)?;
769 let center = Point::new(
770 self.width - HEADER_PAD_X - CHEVRON_SLOT / 2.0,
771 geometry.y + HEADER_PAD_TOP + CHEVRON_SLOT / 2.0,
772 );
773 let angle = self.reveals.get(index).map_or(0.0, |r| r.value) * EXPANDABLE_ICON_ROTATION;
774 let scale = CHEVRON_SIZE / design;
775 let transform = Affine::translate(Vec2::new(center.x, center.y))
778 * Affine::rotate(angle)
779 * Affine::scale(scale)
780 * Affine::translate(Vec2::new(-design / 2.0, -design / 2.0));
781 Some(transform * path.clone())
782 }
783}
784
785impl Widget for ExpandableListWidget {
786 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
787 let width = if bc.max().width.is_finite() {
788 bc.max().width
789 } else {
790 0.0
791 };
792 self.width = width;
793 let header_width = (width - HEADER_PAD_X * 2.0 - CHEVRON_SLOT).max(0.0);
794 let body_width = (width - BODY_PAD_X * 2.0).max(0.0);
795
796 let count = self.item_count();
797 self.geometry.resize(count, ItemGeometry::default());
798 self.laid_out_reveals.resize(count, 0.0);
799 let mut y = 0.0;
800 for index in 0..count {
801 let header_bc = BoxConstraints::new(
802 Size::new(header_width, 0.0),
803 Size::new(header_width, f64::INFINITY),
804 );
805 let header_size = self.pods[index * 2].layout_child(ctx, &header_bc);
806 let header = header_size.height.max(CHEVRON_SLOT) + HEADER_PAD_TOP + HEADER_PAD_BOTTOM;
809 self.pods[index * 2].set_origin(Point::new(HEADER_PAD_X, y + HEADER_PAD_TOP));
810
811 let body_bc = BoxConstraints::new(
814 Size::new(body_width, 0.0),
815 Size::new(body_width, f64::INFINITY),
816 );
817 let body_size = self.pods[index * 2 + 1].layout_child(ctx, &body_bc);
818 let body_full = body_size.height + BODY_PAD_TOP + BODY_PAD_BOTTOM;
819 self.pods[index * 2 + 1].set_origin(Point::new(BODY_PAD_X, y + header + BODY_PAD_TOP));
820
821 let reveal = self.reveals.get(index).map_or(0.0, |r| r.value);
822 self.laid_out_reveals[index] = reveal;
823 let height = header + (reveal * body_full).max(0.0);
824 self.geometry[index] = ItemGeometry {
825 y,
826 header,
827 body_full,
828 height,
829 };
830 y += height;
831 if index + 1 < count {
832 y += self.gap;
833 }
834 }
835
836 bc.constrain(Size::new(width, y))
837 }
838
839 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
840 let theme = Theme::from_paint_ctx(ctx);
843 let container = resolve_container(theme, self.color);
844 let content = resolve_content(theme);
845 let chevron_color = resolve_chevron(theme);
846 let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
847
848 let now = ctx.frame_time();
860 for reveal in &mut self.reveals {
861 if reduce_motion {
862 reveal.snap();
863 } else if reveal.advance(now) {
864 ctx.request_layout();
865 }
866 }
867 if self
868 .reveals
869 .iter()
870 .map(|r| r.value)
871 .ne(self.laid_out_reveals.iter().copied())
872 {
873 ctx.request_layout();
874 }
875
876 let hovered = self.interactive && ctx.is_hovered();
880 self.state.set_hovered(hovered);
881 if !hovered && !self.state.pressed {
882 self.active = None;
883 }
884
885 let origin = ctx.origin();
886 let overlay_opacity = if self.interactive {
887 self.state.resolve_opacity()
888 } else {
889 0.0
890 };
891 let count = self.item_count();
892 let radii: Vec<CornerRadii> = (0..count).map(|index| self.radii_at(index)).collect();
893 let chevrons: Vec<Option<BezPath>> =
894 (0..count).map(|index| self.chevron_path(index)).collect();
895
896 for index in 0..count {
897 let Some(geometry) = self.geometry.get(index).copied() else {
898 continue;
899 };
900 let absolute = Point::new(origin.x, origin.y + geometry.y);
901 let size = Size::new(self.width, geometry.height);
902 scene.fill_rounded_rect_radii(absolute, size, radii[index], container);
903 if overlay_opacity > 0.0 && self.active == Some(index) {
904 scene.fill_rounded_rect_radii(
905 absolute,
906 size,
907 radii[index],
908 with_alpha(content, overlay_opacity),
909 );
910 }
911
912 self.pods[index * 2].paint_child(ctx, scene);
913
914 let revealed = geometry.height - geometry.header;
915 if revealed > 0.0 {
916 scene.push_clip(
919 Point::new(absolute.x, absolute.y + geometry.header),
920 Size::new(self.width, revealed),
921 );
922 self.pods[index * 2 + 1].paint_child(ctx, scene);
923 scene.pop_clip();
924 }
925
926 if let Some(path) = &chevrons[index] {
927 scene.fill_path(origin, path, &Brush::Solid(chevron_color));
928 }
929 }
930 }
931
932 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
933 if event.is_broadcast() || !self.interactive {
936 return frust::authoring::route_event(&mut self.pods, ctx, event);
937 }
938 let InputEvent::Pointer(p) = event else {
940 return frust::authoring::route_event(&mut self.pods, ctx, event);
941 };
942 match p.phase {
943 PointerPhase::Down => {
944 let Some(index) = self.header_at(p.position).filter(|_| presses(p)) else {
945 return frust::authoring::route_event(&mut self.pods, ctx, event);
948 };
949 self.armed = Some(index);
950 self.active = Some(index);
951 self.state.set_pressed(true);
952 ctx.capture_pointer();
953 ctx.request_redraw();
954 EventResult::Handled
955 }
956 PointerPhase::Move => {
957 let Some(armed) = self.armed else {
958 let result = frust::authoring::route_event(&mut self.pods, ctx, event);
962 let over = self.header_at(p.position);
963 if over.is_some() {
964 ctx.claim_hover();
965 }
966 let changed = self.state.set_hovered(over.is_some()) || self.active != over;
967 self.active = over;
968 if changed {
969 ctx.request_redraw();
970 }
971 return result;
972 };
973 let still_on_header = self.header_at(p.position) == Some(armed);
974 if self.state.set_pressed(still_on_header) {
975 ctx.request_redraw();
976 }
977 EventResult::Handled
978 }
979 PointerPhase::Up => {
980 let Some(armed) = self.armed else {
981 return frust::authoring::route_event(&mut self.pods, ctx, event);
982 };
983 if self.header_at(p.position) == Some(armed) {
984 if self.haptic != HapticSignal::None {
985 MaterialHaptics::fire(self.haptic);
986 }
987 if let Some(on_toggle) = self.on_toggle.as_mut() {
988 (on_toggle)(ctx, armed);
989 }
990 }
991 self.clear_press();
992 ctx.request_redraw();
993 EventResult::Handled
994 }
995 PointerPhase::Cancel => {
996 if self.armed.is_none() {
997 return frust::authoring::route_event(&mut self.pods, ctx, event);
998 }
999 self.clear_press();
1001 ctx.request_redraw();
1002 EventResult::Handled
1003 }
1004 }
1005 }
1006
1007 fn semantics(&self, ctx: &mut SemanticsCtx) {
1008 ctx.push_container(
1009 Role::List,
1010 |node| {
1011 if self.interactive {
1012 node.add_action(Action::Click);
1013 }
1014 },
1015 |ctx| {
1016 for pod in &self.pods {
1017 pod.semantics_child(ctx);
1018 }
1019 },
1020 );
1021 }
1022
1023 frust::authoring::visit_children!(pods);
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028 use super::*;
1029 use frust::authoring::text::TextContext;
1030 use frust_widgets::test_support::leaf_any;
1031 use kurbo::Shape;
1032 use std::any::Any;
1033
1034 const WIDTH: f64 = 320.0;
1035 const HEADER_CHILD: f64 = 20.0;
1036 const BODY_CHILD: f64 = 60.0;
1037
1038 fn header_height() -> f64 {
1041 HEADER_CHILD.max(CHEVRON_SLOT) + HEADER_PAD_TOP + HEADER_PAD_BOTTOM
1042 }
1043
1044 fn body_height() -> f64 {
1045 BODY_CHILD + BODY_PAD_TOP + BODY_PAD_BOTTOM
1046 }
1047
1048 fn items(count: usize) -> Vec<ExpandableItem<()>> {
1049 (0..count)
1050 .map(|_| ExpandableItem {
1051 header: leaf_any(40.0, HEADER_CHILD),
1052 body: leaf_any(40.0, BODY_CHILD),
1053 })
1054 .collect()
1055 }
1056
1057 fn build<S: 'static>(view: &ExpandableListView<S>) -> ExpandableListWidget {
1058 let mut counter = 0u64;
1059 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1060 }
1061
1062 fn layout(w: &mut ExpandableListWidget) -> Size {
1063 let mut tcx = TextContext::new();
1064 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1065 w.layout(
1066 &mut lctx,
1067 &BoxConstraints::new(Size::new(WIDTH, 0.0), Size::new(WIDTH, f64::INFINITY)),
1068 )
1069 }
1070
1071 #[derive(Default)]
1072 struct Recorder {
1073 rrects: Vec<(Point, Size, CornerRadii, Color)>,
1074 clips: Vec<(Point, Size)>,
1075 paths: Vec<BezPath>,
1076 }
1077
1078 impl PaintScene for Recorder {
1079 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1080 fn draw_text(&mut self, _o: Point, _t: &str) {}
1081 fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
1082 self.rrects.push((o, s, radii, color));
1083 }
1084 fn push_clip(&mut self, origin: Point, size: Size) {
1085 self.clips.push((origin, size));
1086 }
1087 fn pop_clip(&mut self) {}
1088 fn fill_path(&mut self, origin: Point, path: &BezPath, _brush: &Brush) {
1089 self.paths
1090 .push(Affine::translate(Vec2::new(origin.x, origin.y)) * path.clone());
1091 }
1092 }
1093
1094 fn paint_at(
1096 w: &mut ExpandableListWidget,
1097 t_ms: u64,
1098 theme: Option<&Theme>,
1099 ) -> (Recorder, bool) {
1100 let mut rec = Recorder::default();
1101 let size = Size::new(WIDTH, 4000.0);
1102 let mut ctx =
1103 PaintCtx::for_test(Point::ZERO, size, FrameTime::from_nanos(t_ms * 1_000_000));
1104 if let Some(theme) = theme {
1105 ctx = ctx.with_theme(theme);
1106 }
1107 w.paint(&mut ctx, &mut rec);
1108 let needs_layout = ctx.needs_layout();
1109 (rec, needs_layout)
1110 }
1111
1112 fn dispatch(
1113 w: &mut ExpandableListWidget,
1114 state: &mut dyn Any,
1115 phase: PointerPhase,
1116 pos: Point,
1117 ) -> EventResult {
1118 let event = InputEvent::Pointer(frust::authoring::PointerEvent {
1119 phase,
1120 position: pos,
1121 button: frust::authoring::PointerButton::Primary,
1122 });
1123 let mut ctx = EventCtx::new(state, Point::ZERO, Size::new(WIDTH, 4000.0));
1124 w.event(&mut ctx, &event)
1125 }
1126
1127 #[test]
1130 fn single_open_closes_every_other_item() {
1131 let open: BTreeSet<usize> = [0, 1].into_iter().collect();
1132 let next = toggle_expanded(&open, 2, ExpandMode::SingleOpen);
1133 assert_eq!(next, [2].into_iter().collect::<BTreeSet<usize>>());
1134 }
1135
1136 #[test]
1137 fn independent_mode_keeps_the_others_open() {
1138 let open: BTreeSet<usize> = [0, 1].into_iter().collect();
1139 let next = toggle_expanded(&open, 2, ExpandMode::Independent);
1140 assert_eq!(next, [0, 1, 2].into_iter().collect::<BTreeSet<usize>>());
1141 }
1142
1143 #[test]
1144 fn collapsing_is_mode_independent() {
1145 let open: BTreeSet<usize> = [0, 1].into_iter().collect();
1146 assert_eq!(
1147 toggle_expanded(&open, 1, ExpandMode::SingleOpen),
1148 [0].into_iter().collect::<BTreeSet<usize>>()
1149 );
1150 assert_eq!(
1151 toggle_expanded(&open, 1, ExpandMode::Independent),
1152 [0].into_iter().collect::<BTreeSet<usize>>()
1153 );
1154 }
1155
1156 #[test]
1157 fn is_expanded_reads_the_app_owned_set() {
1158 let open: BTreeSet<usize> = [3].into_iter().collect();
1159 assert!(is_expanded(&open, 3));
1160 assert!(!is_expanded(&open, 0));
1161 }
1162
1163 #[test]
1164 fn single_open_is_the_default_mode() {
1165 assert_eq!(ExpandMode::default(), ExpandMode::SingleOpen);
1166 }
1167
1168 #[test]
1171 fn a_collapsed_item_reports_only_its_header_height() {
1172 let view: ExpandableListView<()> = expandable_list(items(1));
1173 let mut w = build(&view);
1174 let size = layout(&mut w);
1175 assert_eq!(size.height, header_height());
1176 assert_eq!(w.geometry[0].header, header_height());
1177 assert_eq!(
1178 w.geometry[0].body_full,
1179 body_height(),
1180 "the body is measured in full even while collapsed"
1181 );
1182 }
1183
1184 #[test]
1185 fn an_item_built_expanded_reports_header_plus_full_body_on_the_first_layout() {
1186 let view: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1187 let mut w = build(&view);
1188 let size = layout(&mut w);
1189 assert!((size.height - (header_height() + body_height())).abs() < 1e-9);
1190 }
1191
1192 #[test]
1193 fn a_mid_reveal_height_sits_between_collapsed_and_expanded() {
1194 let view: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1195 let mut w = build(&view);
1196 layout(&mut w);
1197 w.reveals[0].value = 0.5;
1198 let size = layout(&mut w);
1199 assert!((size.height - (header_height() + 0.5 * body_height())).abs() < 1e-9);
1200 }
1201
1202 #[test]
1203 fn items_stack_with_the_gap_between_them() {
1204 let view: ExpandableListView<()> = expandable_list(items(3));
1205 let mut w = build(&view);
1206 let size = layout(&mut w);
1207 assert_eq!(size.height, header_height() * 3.0 + EXPANDABLE_GAP * 2.0);
1208 assert_eq!(w.geometry[0].y, 0.0);
1209 assert_eq!(w.geometry[1].y, header_height() + EXPANDABLE_GAP);
1210 }
1211
1212 #[test]
1215 fn paint_requests_layout_through_the_settle_frame() {
1216 let collapsed: ExpandableListView<()> = expandable_list(items(1));
1227 let mut w = build(&collapsed);
1228 layout(&mut w);
1229 let (_, settled) = paint_at(&mut w, 0, None);
1230 assert!(!settled, "an already-settled stack requests no layout");
1231
1232 let expanded: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1233 let mut counter = 0u64;
1234 let flags = <ExpandableListView<()> as View<()>>::rebuild(
1235 &expanded,
1236 &collapsed,
1237 &mut w,
1238 &mut BuildCtx::new(&mut counter),
1239 );
1240 assert!(flags.needs_layout());
1241
1242 let mut frames = 0u32;
1243 let mut heights = Vec::new();
1244 let mut t_ms = 0u64;
1245 let mut needs_layout = true;
1246 loop {
1247 if !needs_layout {
1248 break;
1249 }
1250 heights.push(layout(&mut w).height);
1251 let (_, next_needs_layout) = paint_at(&mut w, t_ms, None);
1252 frames += 1;
1253 assert!(
1254 frames < 600,
1255 "the reveal should settle well inside 600 frames"
1256 );
1257 needs_layout = next_needs_layout;
1258 t_ms += 16;
1259 }
1260 assert!(frames > 1, "the reveal spans more than one frame");
1261 let collapsed_h = header_height();
1262 let expanded_h = header_height() + body_height();
1263 assert!(
1268 heights
1269 .iter()
1270 .all(|h| *h >= collapsed_h - 1e-9 && *h <= expanded_h + 1e-9),
1271 "every laid-out frame stays inside [{collapsed_h}, {expanded_h}]: {heights:?}"
1272 );
1273 let peak = heights
1276 .iter()
1277 .position(|h| *h >= expanded_h - 1e-9)
1278 .expect("the reveal reaches its target");
1279 assert!(peak > 0, "the first frame is not already fully revealed");
1280 assert!(
1281 heights[..=peak]
1282 .windows(2)
1283 .all(|pair| pair[1] >= pair[0] - 1e-9),
1284 "the reveal rises to the target without backsliding: {heights:?}"
1285 );
1286 let last = heights.last().copied().expect("at least one frame");
1289 assert_eq!(
1290 last, expanded_h,
1291 "the last layout a shell-honest driver runs lands exactly on the target height"
1292 );
1293 let (_, still) = paint_at(&mut w, t_ms + 16, None);
1295 assert!(!still, "settled: no more layout requests");
1296 }
1297
1298 #[test]
1299 fn reduce_motion_snaps_the_reveal_and_requests_one_relayout_no_animation() {
1300 let mut theme = crate::baseline();
1306 theme.motion.reduce_motion = true;
1307 let collapsed: ExpandableListView<()> = expandable_list(items(1));
1308 let mut w = build(&collapsed);
1309 let collapsed_h = layout(&mut w).height;
1310 assert_eq!(collapsed_h, header_height(), "starts fully collapsed");
1311
1312 let expanded: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1313 let mut counter = 0u64;
1314 <ExpandableListView<()> as View<()>>::rebuild(
1315 &expanded,
1316 &collapsed,
1317 &mut w,
1318 &mut BuildCtx::new(&mut counter),
1319 );
1320
1321 let (_, needs_layout) = paint_at(&mut w, 0, Some(&theme));
1322 assert_eq!(w.reveals[0].value, 1.0, "snapped straight to the target");
1323 assert!(
1324 needs_layout,
1325 "the snap changed a layout-relevant value, so it must request one relayout"
1326 );
1327
1328 let expanded_h = header_height() + body_height();
1332 let snapped_h = layout(&mut w).height;
1333 assert_eq!(
1334 snapped_h, expanded_h,
1335 "the item's height reaches the target on the snap frame, not some later one"
1336 );
1337
1338 let (_, still) = paint_at(&mut w, 16, Some(&theme));
1340 assert!(
1341 !still,
1342 "a snap requests exactly one relayout, never an ongoing frame request"
1343 );
1344 }
1345
1346 #[test]
1347 fn only_the_toggled_item_animates() {
1348 let collapsed: ExpandableListView<()> = expandable_list(items(3));
1349 let mut w = build(&collapsed);
1350 layout(&mut w);
1351 let expanded: ExpandableListView<()> = expandable_list(items(3)).expanded([1]);
1352 let mut counter = 0u64;
1353 <ExpandableListView<()> as View<()>>::rebuild(
1354 &expanded,
1355 &collapsed,
1356 &mut w,
1357 &mut BuildCtx::new(&mut counter),
1358 );
1359 let mut t_ms = 0u64;
1360 loop {
1361 let (rec, needs_layout) = paint_at(&mut w, t_ms, None);
1362 assert!(
1363 rec.clips.len() <= 1,
1364 "only the toggled item ever reveals a body clip, got {}",
1365 rec.clips.len()
1366 );
1367 if !needs_layout {
1368 break;
1369 }
1370 layout(&mut w);
1371 t_ms += 16;
1372 assert!(t_ms < 10_000, "the reveal should settle well inside 10s");
1373 }
1374 assert_eq!(w.reveals[0].value, 0.0);
1375 assert_eq!(w.reveals[2].value, 0.0);
1376 assert_eq!(w.reveals[1].value, 1.0);
1377 }
1378
1379 #[test]
1380 fn the_body_clip_covers_exactly_the_revealed_band() {
1381 let view: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1382 let mut w = build(&view);
1383 layout(&mut w);
1384 let (rec, _) = paint_at(&mut w, 0, None);
1385 assert_eq!(rec.clips.len(), 1);
1386 let (clip_origin, clip_size) = rec.clips[0];
1387 assert_eq!(clip_origin.y, header_height());
1388 assert!((clip_size.height - body_height()).abs() < 1e-9);
1389 assert_eq!(clip_size.width, WIDTH);
1390 }
1391
1392 #[test]
1393 fn a_collapsed_item_paints_no_body_clip() {
1394 let view: ExpandableListView<()> = expandable_list(items(2));
1395 let mut w = build(&view);
1396 layout(&mut w);
1397 let (rec, _) = paint_at(&mut w, 0, None);
1398 assert!(rec.clips.is_empty());
1399 }
1400
1401 #[test]
1402 fn the_chevron_rotates_with_the_reveal() {
1403 let closed: ExpandableListView<()> = expandable_list(items(1));
1404 let mut w = build(&closed);
1405 layout(&mut w);
1406 let (rec_closed, _) = paint_at(&mut w, 0, None);
1407 let closed_box = rec_closed.paths[0].bounding_box();
1408
1409 let open: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1410 let mut w2 = build(&open);
1411 layout(&mut w2);
1412 let (rec_open, _) = paint_at(&mut w2, 0, None);
1413 let open_box = rec_open.paths[0].bounding_box();
1414
1415 let slot_x = WIDTH - HEADER_PAD_X - CHEVRON_SLOT / 2.0;
1419 let slot_y = HEADER_PAD_TOP + CHEVRON_SLOT / 2.0;
1420 assert!(
1421 (closed_box.center().x + open_box.center().x - 2.0 * slot_x).abs() < 1e-6,
1422 "closed {closed_box:?} / open {open_box:?} about x={slot_x}"
1423 );
1424 assert!(
1425 (closed_box.center().y + open_box.center().y - 2.0 * slot_y).abs() < 1e-6,
1426 "closed {closed_box:?} / open {open_box:?} about y={slot_y}"
1427 );
1428 assert_ne!(
1429 rec_closed.paths[0].to_svg(),
1430 rec_open.paths[0].to_svg(),
1431 "the chevron path must actually rotate"
1432 );
1433 }
1434
1435 #[test]
1436 fn the_reveal_spring_matches_the_reference_preset() {
1437 let reference = crate::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT;
1438 assert_eq!(REVEAL_SPRING.mass, 1.0);
1439 assert_eq!(REVEAL_SPRING.stiffness, reference.stiffness);
1440 assert_eq!(REVEAL_SPRING.damping_ratio, reference.damping_ratio);
1441 }
1442
1443 #[test]
1446 fn item_corners_follow_the_card_list_position_matrix() {
1447 let view: ExpandableListView<()> = expandable_list(items(3));
1448 let mut w = build(&view);
1449 layout(&mut w);
1450 let (rec, _) = paint_at(&mut w, 0, None);
1451 let radii: Vec<CornerRadii> = rec.rrects.iter().map(|(_, _, r, _)| *r).collect();
1452 let (outer, inner) = (EXPANDABLE_OUTER_RADIUS, EXPANDABLE_INNER_RADIUS);
1453 assert_eq!(
1454 radii,
1455 vec![
1456 card_radii(crate::CardPosition::First, outer, inner),
1457 card_radii(crate::CardPosition::Middle, outer, inner),
1458 card_radii(crate::CardPosition::Last, outer, inner),
1459 ]
1460 );
1461 }
1462
1463 #[test]
1464 fn an_expanded_radius_override_rounds_the_open_item_uniformly() {
1465 let view: ExpandableListView<()> = expandable_list(items(2))
1466 .expanded([0])
1467 .expanded_radius(28.0);
1468 let mut w = build(&view);
1469 layout(&mut w);
1470 let (rec, _) = paint_at(&mut w, 0, None);
1471 assert_eq!(rec.rrects[0].2, CornerRadii::uniform(28.0));
1472 assert_ne!(rec.rrects[1].2, CornerRadii::uniform(28.0));
1473 }
1474
1475 #[test]
1476 fn a_pressed_item_swaps_in_the_pressed_inner_radius() {
1477 let view: ExpandableListView<()> = expandable_list(items(2)).on_toggle(|_: &mut (), _| {});
1478 let mut w = build(&view);
1479 layout(&mut w);
1480 let mut state = ();
1481 dispatch(
1482 &mut w,
1483 &mut state,
1484 PointerPhase::Down,
1485 Point::new(10.0, header_height() / 2.0),
1486 );
1487 let expected = card_radii(
1488 crate::CardPosition::First,
1489 EXPANDABLE_OUTER_RADIUS,
1490 EXPANDABLE_PRESSED_RADIUS,
1491 );
1492 assert_eq!(w.radii_at(0), expected);
1493 }
1494
1495 #[test]
1496 fn a_hovered_item_swaps_in_the_hover_inner_radius() {
1497 let view: ExpandableListView<()> = expandable_list(items(2)).on_toggle(|_: &mut (), _| {});
1500 let mut w = build(&view);
1501 layout(&mut w);
1502 let mut state = ();
1503 let second_y = w.geometry[1].y + 1.0;
1504 dispatch(
1505 &mut w,
1506 &mut state,
1507 PointerPhase::Move,
1508 Point::new(10.0, second_y),
1509 );
1510 assert_eq!(w.active, Some(1));
1511 assert!(w.state.hovered);
1512 assert_eq!(
1513 w.radii_at(1),
1514 card_radii(
1515 crate::CardPosition::Last,
1516 EXPANDABLE_OUTER_RADIUS,
1517 EXPANDABLE_HOVER_RADIUS,
1518 )
1519 );
1520 assert_eq!(
1522 w.radii_at(0),
1523 card_radii(
1524 crate::CardPosition::First,
1525 EXPANDABLE_OUTER_RADIUS,
1526 EXPANDABLE_INNER_RADIUS,
1527 )
1528 );
1529 }
1530
1531 #[test]
1534 fn a_header_press_released_inside_reports_its_index() {
1535 #[derive(Default)]
1536 struct S {
1537 toggled: Vec<usize>,
1538 }
1539 let view: ExpandableListView<S> = expandable_list((0..3).map(|_| {
1540 expandable_item(
1541 frust::SizedBox::<S>(Some(40.0), Some(HEADER_CHILD)),
1542 frust::SizedBox::<S>(Some(40.0), Some(BODY_CHILD)),
1543 )
1544 }))
1545 .on_toggle(|s: &mut S, index| s.toggled.push(index));
1546 let mut w = build(&view);
1547 layout(&mut w);
1548 let mut state = S::default();
1549 let y = w.geometry[1].y + header_height() / 2.0;
1550 dispatch(&mut w, &mut state, PointerPhase::Down, Point::new(10.0, y));
1551 dispatch(&mut w, &mut state, PointerPhase::Up, Point::new(10.0, y));
1552 assert_eq!(state.toggled, vec![1]);
1553 assert!(w.expanded.is_empty());
1555 }
1556
1557 #[test]
1558 fn a_release_outside_the_armed_header_fires_nothing() {
1559 #[derive(Default)]
1560 struct S {
1561 toggled: Vec<usize>,
1562 }
1563 let view: ExpandableListView<S> = expandable_list((0..2).map(|_| {
1564 expandable_item(
1565 frust::SizedBox::<S>(Some(40.0), Some(HEADER_CHILD)),
1566 frust::SizedBox::<S>(Some(40.0), Some(BODY_CHILD)),
1567 )
1568 }))
1569 .on_toggle(|s: &mut S, index| s.toggled.push(index));
1570 let mut w = build(&view);
1571 layout(&mut w);
1572 let mut state = S::default();
1573 let y0 = header_height() / 2.0;
1574 let y1 = w.geometry[1].y + header_height() / 2.0;
1575 dispatch(&mut w, &mut state, PointerPhase::Down, Point::new(10.0, y0));
1576 dispatch(&mut w, &mut state, PointerPhase::Up, Point::new(10.0, y1));
1577 assert!(state.toggled.is_empty());
1578 }
1579
1580 #[test]
1581 fn a_cancel_clears_the_press_without_firing() {
1582 #[derive(Default)]
1583 struct S {
1584 toggled: Vec<usize>,
1585 }
1586 let view: ExpandableListView<S> = expandable_list((0..1).map(|_| {
1587 expandable_item(
1588 frust::SizedBox::<S>(Some(40.0), Some(HEADER_CHILD)),
1589 frust::SizedBox::<S>(Some(40.0), Some(BODY_CHILD)),
1590 )
1591 }))
1592 .on_toggle(|s: &mut S, index| s.toggled.push(index));
1593 let mut w = build(&view);
1594 layout(&mut w);
1595 let mut state = S::default();
1596 let y = header_height() / 2.0;
1597 dispatch(&mut w, &mut state, PointerPhase::Down, Point::new(10.0, y));
1598 dispatch(
1599 &mut w,
1600 &mut state,
1601 PointerPhase::Cancel,
1602 Point::new(10.0, y),
1603 );
1604 assert!(state.toggled.is_empty());
1605 assert!(w.armed.is_none());
1606 assert!(!w.state.pressed);
1607 }
1608
1609 #[test]
1610 fn a_secondary_press_never_arms_the_header() {
1611 let view: ExpandableListView<()> = expandable_list(items(1)).on_toggle(|_: &mut (), _| {});
1612 let mut w = build(&view);
1613 layout(&mut w);
1614 let event = InputEvent::Pointer(frust::authoring::PointerEvent {
1615 phase: PointerPhase::Down,
1616 position: Point::new(10.0, header_height() / 2.0),
1617 button: frust::authoring::PointerButton::Secondary,
1618 });
1619 let mut state = ();
1620 let sa: &mut dyn Any = &mut state;
1621 let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(WIDTH, 4000.0));
1622 w.event(&mut ctx, &event);
1623 assert!(w.armed.is_none());
1624 }
1625
1626 #[test]
1627 fn a_press_in_a_revealed_body_never_arms_the_header() {
1628 let view: ExpandableListView<()> = expandable_list(items(1))
1629 .expanded([0])
1630 .on_toggle(|_: &mut (), _| {});
1631 let mut w = build(&view);
1632 layout(&mut w);
1633 let mut state = ();
1634 let in_body = header_height() + 10.0;
1635 dispatch(
1636 &mut w,
1637 &mut state,
1638 PointerPhase::Down,
1639 Point::new(10.0, in_body),
1640 );
1641 assert!(w.armed.is_none());
1642 }
1643
1644 #[test]
1645 fn a_stack_without_on_toggle_routes_pointers_to_its_children() {
1646 let view: ExpandableListView<()> = expandable_list(items(1));
1647 let mut w = build(&view);
1648 layout(&mut w);
1649 let mut state = ();
1650 dispatch(
1651 &mut w,
1652 &mut state,
1653 PointerPhase::Down,
1654 Point::new(10.0, header_height() / 2.0),
1655 );
1656 assert!(w.armed.is_none());
1657 }
1658
1659 #[test]
1660 fn header_hit_testing_ignores_the_gap_between_items() {
1661 let view: ExpandableListView<()> = expandable_list(items(2));
1662 let mut w = build(&view);
1663 layout(&mut w);
1664 let gap_y = header_height() + EXPANDABLE_GAP / 2.0;
1665 assert_eq!(w.header_at(Point::new(10.0, gap_y)), None);
1666 assert_eq!(w.header_at(Point::new(10.0, 1.0)), Some(0));
1667 assert_eq!(
1668 w.header_at(Point::new(10.0, w.geometry[1].y + 1.0)),
1669 Some(1)
1670 );
1671 }
1672
1673 #[test]
1676 fn a_structural_change_reseeds_the_reveal_drivers() {
1677 let two: ExpandableListView<()> = expandable_list(items(2)).expanded([1]);
1678 let mut w = build(&two);
1679 layout(&mut w);
1680 assert_eq!(w.reveals.len(), 2);
1681 let three: ExpandableListView<()> = expandable_list(items(3)).expanded([2]);
1682 let mut counter = 0u64;
1683 <ExpandableListView<()> as View<()>>::rebuild(
1684 &three,
1685 &two,
1686 &mut w,
1687 &mut BuildCtx::new(&mut counter),
1688 );
1689 assert_eq!(w.reveals.len(), 3);
1690 assert_eq!(w.reveals[2].value, 1.0, "the new set seeds at rest");
1691 assert_eq!(w.reveals[1].value, 0.0);
1692 }
1693
1694 #[test]
1695 fn semantics_is_a_clickable_list_forwarding_every_pod() {
1696 fn logic(_s: &mut ()) -> ExpandableListView<()> {
1697 expandable_list([expandable_item(frust::text("header"), frust::text("body"))])
1698 .on_toggle(|_: &mut (), _| {})
1699 }
1700 let mut root: frust_core::RenderRoot<(), ExpandableListView<()>> =
1701 frust_core::RenderRoot::new();
1702 let mut state = ();
1703 root.rebuild(&mut logic, &mut state);
1704 let mut tcx = TextContext::new();
1705 root.layout_with_text(Size::new(WIDTH, 800.0), &mut tcx as &mut dyn Any);
1706 let update = root.semantics();
1707 let (_, node) = update
1708 .nodes
1709 .iter()
1710 .find(|(_, n)| n.role() == Role::List)
1711 .expect("the stack contributes a Role::List node");
1712 assert!(
1713 node.supports_action(Action::Click),
1714 "an interactive stack carries Action::Click"
1715 );
1716 assert!(
1717 !node.children().is_empty(),
1718 "every header and body pod is a semantics child"
1719 );
1720 }
1721}