1use std::collections::BTreeSet;
111use std::rc::Rc;
112
113use frust::Theme;
114use frust::authoring::{Action, Role};
115use frust::authoring::{
116 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CornerRadii, ErasedArgCallback,
117 EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
118 View, Widget,
119};
120use kurbo::{Point, Size};
121use peniko::Color;
122
123use crate::interaction::{
124 DISABLED_CONTAINER_OPACITY, HapticSignal, InteractionState, MaterialHaptics,
125};
126use crate::list_item::ListItem;
127
128use super::press::presses;
129
130pub const CARD_LIST_OUTER_RADIUS: f64 = 24.0;
133pub const CARD_LIST_INNER_RADIUS: f64 = 4.0;
136pub const CARD_LIST_GAP: f64 = 4.0;
139pub const CARD_LIST_ITEM_PADDING: f64 = 12.0;
142
143const FILLED_CONTAINER: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
146const SELECTED_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
149const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
152
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub enum CardPosition {
157 First,
159 Middle,
161 Last,
163 Single,
165}
166
167pub fn card_position(index: usize, total: usize) -> CardPosition {
172 if total <= 1 {
173 CardPosition::Single
174 } else if index == 0 {
175 CardPosition::First
176 } else if index + 1 >= total {
177 CardPosition::Last
178 } else {
179 CardPosition::Middle
180 }
181}
182
183pub fn card_radii(position: CardPosition, outer: f64, inner: f64) -> CornerRadii {
186 match position {
187 CardPosition::Single => CornerRadii::uniform(outer),
189 CardPosition::First => CornerRadii::new(outer, outer, inner, inner),
190 CardPosition::Last => CornerRadii::new(inner, inner, outer, outer),
191 CardPosition::Middle => CornerRadii::uniform(inner),
192 }
193}
194
195type OnPressIndex<State> = Rc<dyn Fn(&mut State, usize)>;
197
198pub struct CardListView<State: 'static> {
200 items: Vec<AnyView<State>>,
201 outer_radius: f64,
202 inner_radius: f64,
203 gap: f64,
204 item_padding: f64,
205 color: Option<Color>,
206 selected: BTreeSet<usize>,
207 enabled: bool,
208 haptic: HapticSignal,
209 on_press: Option<OnPressIndex<State>>,
210}
211
212pub fn card_list<State: 'static>(
216 items: impl IntoIterator<Item = AnyView<State>>,
217) -> CardListView<State> {
218 CardListView {
219 items: items.into_iter().collect(),
220 outer_radius: CARD_LIST_OUTER_RADIUS,
221 inner_radius: CARD_LIST_INNER_RADIUS,
222 gap: CARD_LIST_GAP,
223 item_padding: CARD_LIST_ITEM_PADDING,
224 color: None,
225 selected: BTreeSet::new(),
226 enabled: true,
227 haptic: HapticSignal::None,
228 on_press: None,
229 }
230}
231
232pub fn card_list_items<State: 'static>(
237 items: impl IntoIterator<Item = ListItem<State>>,
238) -> CardListView<State> {
239 card_list(items.into_iter().map(frust::authoring::any))
240}
241
242impl<State: 'static> CardListView<State> {
243 pub fn outer_radius(mut self, radius: f64) -> Self {
246 self.outer_radius = radius;
247 self
248 }
249
250 pub fn inner_radius(mut self, radius: f64) -> Self {
253 self.inner_radius = radius;
254 self
255 }
256
257 pub fn gap(mut self, gap: f64) -> Self {
260 self.gap = gap;
261 self
262 }
263
264 pub fn item_padding(mut self, padding: f64) -> Self {
267 self.item_padding = padding;
268 self
269 }
270
271 pub fn color(mut self, color: Color) -> Self {
274 self.color = Some(color);
275 self
276 }
277
278 pub fn selected(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
281 self.selected = indices.into_iter().collect();
282 self
283 }
284
285 pub fn enabled(mut self, enabled: bool) -> Self {
289 self.enabled = enabled;
290 self
291 }
292
293 pub fn haptic(mut self, haptic: HapticSignal) -> Self {
297 self.haptic = haptic;
298 self
299 }
300
301 pub fn on_press<F: Fn(&mut State, usize) + 'static>(mut self, on_press: F) -> Self {
305 self.on_press = Some(Rc::new(on_press));
306 self
307 }
308}
309
310pub struct CardListWidget {
312 items: Vec<ChildPod>,
313 outer_radius: f64,
314 inner_radius: f64,
315 gap: f64,
316 item_padding: f64,
317 color: Option<Color>,
318 selected: BTreeSet<usize>,
319 interactive: bool,
320 enabled: bool,
321 haptic: HapticSignal,
322 cards: Vec<(f64, f64)>,
325 width: f64,
328 armed: Option<usize>,
331 active: Option<usize>,
333 state: InteractionState,
334 on_press: Option<ErasedArgCallback<usize>>,
335}
336
337fn with_alpha(color: Color, alpha: f32) -> Color {
340 let c = color.components;
341 Color::new([c[0], c[1], c[2], alpha])
342}
343
344fn resolve_content_color(theme: Option<&Theme>) -> Color {
347 match theme {
348 Some(theme) => theme.scheme().on_surface,
349 None => ON_SURFACE,
350 }
351}
352
353fn resolve_card_color(
358 theme: Option<&Theme>,
359 override_color: Option<Color>,
360 selected: bool,
361 disabled: bool,
362) -> Color {
363 if disabled {
364 return with_alpha(resolve_content_color(theme), DISABLED_CONTAINER_OPACITY);
365 }
366 if selected {
367 return match theme {
368 Some(theme) => theme.scheme().secondary_container,
369 None => SELECTED_CONTAINER,
370 };
371 }
372 if let Some(color) = override_color {
373 return color;
374 }
375 match theme {
376 Some(theme) => theme.scheme().surface_container_highest,
377 None => FILLED_CONTAINER,
378 }
379}
380
381impl<State: 'static> View<State> for CardListView<State> {
382 type Element = CardListWidget;
383
384 fn build(&self, ctx: &mut BuildCtx<'_>) -> CardListWidget {
385 let items = self
386 .items
387 .iter()
388 .map(|view| frust::authoring::build_child(view, ctx))
389 .collect();
390 CardListWidget {
391 items,
392 outer_radius: self.outer_radius,
393 inner_radius: self.inner_radius,
394 gap: self.gap,
395 item_padding: self.item_padding,
396 color: self.color,
397 selected: self.selected.clone(),
398 interactive: self.on_press.is_some(),
399 enabled: self.enabled,
400 haptic: self.haptic,
401 cards: Vec::new(),
402 width: 0.0,
403 armed: None,
404 active: None,
405 state: InteractionState::new(),
406 on_press: self
407 .on_press
408 .as_ref()
409 .map(frust::authoring::erase_callback_arg),
410 }
411 }
412
413 fn rebuild(
414 &self,
415 prev: &Self,
416 element: &mut CardListWidget,
417 ctx: &mut BuildCtx<'_>,
418 ) -> ChangeFlags {
419 let mut flags = frust::authoring::rebuild_children(
420 &prev.items,
421 &self.items,
422 &mut element.items,
423 ctx,
424 |view| view,
425 |_| None,
426 );
427
428 if prev.items.len() != self.items.len() {
429 element.armed = None;
432 element.active = None;
433 element.state.set_pressed(false);
434 element.state.set_hovered(false);
435 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
436 }
437
438 if element.outer_radius != self.outer_radius
439 || element.inner_radius != self.inner_radius
440 || element.color != self.color
441 || element.selected != self.selected
442 {
443 element.outer_radius = self.outer_radius;
444 element.inner_radius = self.inner_radius;
445 element.color = self.color;
446 element.selected = self.selected.clone();
447 flags |= ChangeFlags::PAINT;
448 }
449 if element.gap != self.gap || element.item_padding != self.item_padding {
450 element.gap = self.gap;
451 element.item_padding = self.item_padding;
452 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
453 }
454
455 let now_interactive = self.on_press.is_some();
456 if element.interactive != now_interactive {
457 element.interactive = now_interactive;
458 if !now_interactive {
459 element.armed = None;
462 element.active = None;
463 element.state.set_pressed(false);
464 element.state.set_hovered(false);
465 }
466 flags |= ChangeFlags::PAINT;
467 }
468 if element.enabled != self.enabled {
469 element.enabled = self.enabled;
470 if !self.enabled {
471 element.armed = None;
472 element.active = None;
473 element.state.set_pressed(false);
474 element.state.set_hovered(false);
475 }
476 flags |= ChangeFlags::PAINT;
477 }
478 element.haptic = self.haptic;
479 element.on_press = self
480 .on_press
481 .as_ref()
482 .map(frust::authoring::erase_callback_arg);
483 flags
484 }
485
486 fn teardown(&self, element: &mut CardListWidget, ctx: &mut BuildCtx<'_>) {
487 for (view, pod) in self.items.iter().zip(element.items.iter_mut()) {
488 frust::authoring::teardown_child(view, pod, ctx);
489 }
490 }
491}
492
493impl CardListWidget {
494 fn card_rect(&self, index: usize) -> Option<(Point, Size)> {
497 let (y, height) = *self.cards.get(index)?;
498 Some((Point::new(0.0, y), Size::new(self.width, height)))
499 }
500
501 fn card_at(&self, pos: Point) -> Option<usize> {
504 if pos.x < 0.0 || pos.x >= self.width {
505 return None;
506 }
507 self.cards
508 .iter()
509 .position(|(y, height)| pos.y >= *y && pos.y < y + height)
510 }
511
512 fn radii_at(&self, index: usize) -> CornerRadii {
514 card_radii(
515 card_position(index, self.items.len()),
516 self.outer_radius,
517 self.inner_radius,
518 )
519 }
520
521 fn clear_press(&mut self) {
524 self.armed = None;
525 self.state.set_pressed(false);
526 if !self.state.is_active() {
527 self.active = None;
528 }
529 }
530}
531
532impl Widget for CardListWidget {
533 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
534 let width = if bc.max().width.is_finite() {
535 bc.max().width
536 } else {
537 0.0
538 };
539 self.width = width;
540 let inner_width = (width - self.item_padding * 2.0).max(0.0);
541 let max_height = bc.max().height;
542 let child_bc = BoxConstraints::new(
545 Size::new(inner_width, 0.0),
546 Size::new(inner_width, max_height),
547 );
548
549 self.cards.clear();
550 let mut y = 0.0;
551 let count = self.items.len();
552 for (index, pod) in self.items.iter_mut().enumerate() {
553 let child = pod.layout_child(ctx, &child_bc);
554 let card_height = child.height + self.item_padding * 2.0;
555 pod.set_origin(Point::new(self.item_padding, y + self.item_padding));
556 self.cards.push((y, card_height));
557 y += card_height;
558 if index + 1 < count {
559 y += self.gap;
560 }
561 }
562
563 bc.constrain(Size::new(width, y))
564 }
565
566 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
567 let theme = Theme::from_paint_ctx(ctx);
570 let disabled = self.interactive && !self.enabled;
571 let content_color = resolve_content_color(theme);
572 let fills: Vec<Color> = (0..self.items.len())
573 .map(|index| {
574 resolve_card_color(theme, self.color, self.selected.contains(&index), disabled)
575 })
576 .collect();
577 let hovered = self.interactive && self.enabled && ctx.is_hovered();
581 self.state.set_hovered(hovered);
582 if !hovered && !self.state.pressed {
583 self.active = None;
584 }
585
586 let origin = ctx.origin();
587 let overlay_opacity = if self.interactive && self.enabled {
588 self.state.resolve_opacity()
589 } else {
590 0.0
591 };
592
593 for (index, fill) in fills.iter().enumerate() {
594 let Some((card_origin, size)) = self.card_rect(index) else {
595 continue;
596 };
597 let absolute = Point::new(origin.x + card_origin.x, origin.y + card_origin.y);
598 let radii = self.radii_at(index);
599 scene.fill_rounded_rect_radii(absolute, size, radii, *fill);
600 if overlay_opacity > 0.0 && self.active == Some(index) {
601 scene.fill_rounded_rect_radii(
602 absolute,
603 size,
604 radii,
605 with_alpha(content_color, overlay_opacity),
606 );
607 }
608 }
609
610 for pod in &mut self.items {
611 pod.paint_child(ctx, scene);
612 }
613 }
614
615 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
616 if !self.interactive {
617 return frust::authoring::route_event(&mut self.items, ctx, event);
618 }
619 let InputEvent::Pointer(p) = event else {
623 return frust::authoring::route_event(&mut self.items, ctx, event);
624 };
625 if !self.enabled {
626 return EventResult::Ignored;
630 }
631 match p.phase {
632 PointerPhase::Down => {
633 if !presses(p) {
634 return EventResult::Ignored;
635 }
636 let Some(index) = self.card_at(p.position) else {
637 return EventResult::Ignored;
639 };
640 self.armed = Some(index);
641 self.active = Some(index);
642 self.state.set_pressed(true);
643 ctx.capture_pointer();
644 ctx.request_redraw();
645 EventResult::Handled
646 }
647 PointerPhase::Move => {
648 let Some(armed) = self.armed else {
649 let over = self.card_at(p.position);
653 if over.is_some() {
654 ctx.claim_hover();
655 }
656 let changed = self.state.set_hovered(over.is_some()) || self.active != over;
657 self.active = over;
658 if changed {
659 ctx.request_redraw();
660 }
661 return EventResult::Ignored;
662 };
663 let still_on_card = self.card_at(p.position) == Some(armed);
664 if self.state.set_pressed(still_on_card) {
665 ctx.request_redraw();
666 }
667 EventResult::Handled
668 }
669 PointerPhase::Up => {
670 let Some(armed) = self.armed else {
671 return EventResult::Ignored;
672 };
673 if self.card_at(p.position) == Some(armed) {
674 if self.haptic != HapticSignal::None {
675 MaterialHaptics::fire(self.haptic);
676 }
677 if let Some(on_press) = self.on_press.as_mut() {
678 (on_press)(ctx, armed);
679 }
680 }
681 self.clear_press();
682 ctx.request_redraw();
683 EventResult::Handled
684 }
685 PointerPhase::Cancel => {
686 if self.armed.is_none() {
687 return EventResult::Ignored;
688 }
689 self.clear_press();
690 ctx.request_redraw();
691 EventResult::Handled
692 }
693 }
694 }
695
696 fn semantics(&self, ctx: &mut SemanticsCtx) {
697 ctx.push_container(
698 Role::List,
699 |node| {
700 if self.interactive {
701 if self.enabled {
702 node.add_action(Action::Click);
703 } else {
704 node.set_disabled();
705 }
706 }
707 },
708 |ctx| {
709 for pod in &self.items {
710 pod.semantics_child(ctx);
711 }
712 },
713 );
714 }
715
716 frust::authoring::visit_children!(items);
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722 use frust::authoring::any;
723 use frust::authoring::text::TextContext;
724 use frust_widgets::test_support::leaf_any;
725 use std::any::Any;
726
727 const WIDTH: f64 = 300.0;
728 const CHILD_HEIGHT: f64 = 20.0;
730
731 fn card_height() -> f64 {
732 CHILD_HEIGHT + CARD_LIST_ITEM_PADDING * 2.0
733 }
734
735 fn build<S: 'static>(view: &CardListView<S>) -> CardListWidget {
736 let mut counter = 0u64;
737 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
738 }
739
740 fn layout(w: &mut CardListWidget) -> Size {
741 let mut tcx = TextContext::new();
742 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
743 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(WIDTH, 1000.0)))
744 }
745
746 fn leaves(count: usize) -> Vec<AnyView<()>> {
747 (0..count).map(|_| leaf_any(10.0, CHILD_HEIGHT)).collect()
748 }
749
750 #[derive(Default)]
752 struct Recorder {
753 rrects: Vec<(Point, Size, CornerRadii, Color)>,
754 }
755
756 impl PaintScene for Recorder {
757 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
758 fn draw_text(&mut self, _o: Point, _t: &str) {}
759 fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
760 self.rrects.push((o, s, radii, color));
761 }
762 }
763
764 impl Recorder {
765 fn fills(&self) -> Vec<(Point, CornerRadii, Color)> {
768 self.rrects
769 .iter()
770 .filter(|(_, _, _, c)| c.components[3] >= 1.0)
771 .map(|(o, _, radii, c)| (*o, *radii, *c))
772 .collect()
773 }
774
775 fn overlays(&self) -> Vec<(Point, CornerRadii, f32)> {
777 self.rrects
778 .iter()
779 .filter(|(_, _, _, c)| c.components[3] < 1.0)
780 .map(|(o, _, radii, c)| (*o, *radii, c.components[3]))
781 .collect()
782 }
783 }
784
785 fn paint(w: &mut CardListWidget, theme: Option<&Theme>) -> Recorder {
786 let mut rec = Recorder::default();
787 let size = Size::new(WIDTH, 1000.0);
788 let mut ctx = match theme {
789 Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
790 None => PaintCtx::new(Point::ZERO, size),
791 };
792 w.paint(&mut ctx, &mut rec);
793 rec
794 }
795
796 #[test]
799 fn card_position_classifies_single_first_middle_last() {
800 assert_eq!(card_position(0, 1), CardPosition::Single);
801 assert_eq!(card_position(0, 3), CardPosition::First);
802 assert_eq!(card_position(1, 3), CardPosition::Middle);
803 assert_eq!(card_position(2, 3), CardPosition::Last);
804 assert_eq!(card_position(0, 0), CardPosition::Single);
806 assert_eq!(card_position(9, 3), CardPosition::Last);
807 }
808
809 #[test]
810 fn card_radii_pins_the_reference_corner_matrix() {
811 let (outer, inner) = (CARD_LIST_OUTER_RADIUS, CARD_LIST_INNER_RADIUS);
812 assert_eq!(
813 card_radii(CardPosition::Single, outer, inner),
814 CornerRadii::uniform(outer)
815 );
816 assert_eq!(
817 card_radii(CardPosition::First, outer, inner),
818 CornerRadii::new(outer, outer, inner, inner),
819 "first: outer on top, inner on the joined bottom edge"
820 );
821 assert_eq!(
822 card_radii(CardPosition::Middle, outer, inner),
823 CornerRadii::uniform(inner)
824 );
825 assert_eq!(
826 card_radii(CardPosition::Last, outer, inner),
827 CornerRadii::new(inner, inner, outer, outer),
828 "last: inner on the joined top edge, outer on the bottom"
829 );
830 }
831
832 #[test]
833 fn a_painted_stack_carries_the_position_radii_in_order() {
834 let view: CardListView<()> = card_list(leaves(3));
835 let mut w = build(&view);
836 layout(&mut w);
837 let rec = paint(&mut w, None);
838 let (outer, inner) = (CARD_LIST_OUTER_RADIUS, CARD_LIST_INNER_RADIUS);
839 let radii: Vec<CornerRadii> = rec.fills().iter().map(|(_, r, _)| *r).collect();
840 assert_eq!(
841 radii,
842 vec![
843 card_radii(CardPosition::First, outer, inner),
844 card_radii(CardPosition::Middle, outer, inner),
845 card_radii(CardPosition::Last, outer, inner),
846 ]
847 );
848 }
849
850 #[test]
851 fn a_lone_card_takes_the_single_radii() {
852 let view: CardListView<()> = card_list(leaves(1));
853 let mut w = build(&view);
854 layout(&mut w);
855 let rec = paint(&mut w, None);
856 let fills = rec.fills();
857 assert_eq!(fills.len(), 1);
858 assert_eq!(fills[0].1, CornerRadii::uniform(CARD_LIST_OUTER_RADIUS));
859 }
860
861 #[test]
862 fn overridden_radii_reach_the_painted_cards() {
863 let view: CardListView<()> = card_list(leaves(2)).outer_radius(30.0).inner_radius(2.0);
864 let mut w = build(&view);
865 layout(&mut w);
866 let rec = paint(&mut w, None);
867 let radii: Vec<CornerRadii> = rec.fills().iter().map(|(_, r, _)| *r).collect();
868 assert_eq!(
869 radii,
870 vec![
871 CornerRadii::new(30.0, 30.0, 2.0, 2.0),
872 CornerRadii::new(2.0, 2.0, 30.0, 30.0),
873 ]
874 );
875 }
876
877 #[test]
880 fn cards_stack_with_the_gap_and_pad_their_children() {
881 let view: CardListView<()> = card_list(leaves(3));
882 let mut w = build(&view);
883 let size = layout(&mut w);
884 let card = card_height();
885 assert_eq!(size.width, WIDTH);
886 assert_eq!(size.height, card * 3.0 + CARD_LIST_GAP * 2.0);
887
888 for (index, pod) in w.items.iter().enumerate() {
890 let card_top = (card + CARD_LIST_GAP) * index as f64;
891 assert_eq!(
892 pod.origin(),
893 Point::new(CARD_LIST_ITEM_PADDING, card_top + CARD_LIST_ITEM_PADDING)
894 );
895 }
896 let (last_y, last_h) = w.cards[2];
898 assert_eq!(last_y + last_h, size.height);
899 }
900
901 #[test]
902 fn an_empty_stack_lays_out_and_paints_nothing() {
903 let view: CardListView<()> = card_list(Vec::new());
904 let mut w = build(&view);
905 let size = layout(&mut w);
906 assert_eq!(size.height, 0.0);
907 assert!(paint(&mut w, None).rrects.is_empty());
908 }
909
910 #[test]
913 fn cards_take_the_filled_role_and_selected_indices_the_selection_fill() {
914 let theme = crate::baseline();
915 let view: CardListView<()> = card_list(leaves(3)).selected([1]);
916 let mut w = build(&view);
917 layout(&mut w);
918 let rec = paint(&mut w, Some(&theme));
919 let colors: Vec<Color> = rec.fills().iter().map(|(_, _, c)| *c).collect();
920 let scheme = theme.scheme();
921 assert_eq!(
922 colors,
923 vec![
924 scheme.surface_container_highest,
925 scheme.secondary_container,
926 scheme.surface_container_highest,
927 ]
928 );
929 }
930
931 #[test]
932 fn an_explicit_color_replaces_the_unselected_fill_only() {
933 let theme = crate::baseline();
934 let custom = Color::from_rgb8(0x10, 0x20, 0x30);
935 let view: CardListView<()> = card_list(leaves(2)).color(custom).selected([1]);
936 let mut w = build(&view);
937 layout(&mut w);
938 let rec = paint(&mut w, Some(&theme));
939 let colors: Vec<Color> = rec.fills().iter().map(|(_, _, c)| *c).collect();
940 assert_eq!(colors, vec![custom, theme.scheme().secondary_container]);
941 }
942
943 #[test]
944 fn a_disabled_interactive_stack_dims_every_card_and_paints_no_overlay() {
945 let theme = crate::baseline();
946 let view: CardListView<()> = card_list(leaves(2))
947 .on_press(|_: &mut (), _| {})
948 .selected([0])
949 .enabled(false);
950 let mut w = build(&view);
951 layout(&mut w);
952 let rec = paint(&mut w, Some(&theme));
953 let dimmed = with_alpha(theme.scheme().on_surface, DISABLED_CONTAINER_OPACITY);
954 assert_eq!(rec.rrects.len(), 2);
957 for (_, _, _, color) in &rec.rrects {
958 assert_eq!(*color, dimmed, "every card dims, selected included");
959 }
960 }
961
962 #[derive(Default)]
965 struct Pressed {
966 indices: Vec<usize>,
967 }
968
969 fn dispatch<S: 'static>(
970 w: &mut CardListWidget,
971 state: &mut S,
972 phase: PointerPhase,
973 x: f64,
974 y: f64,
975 ) -> EventResult {
976 let state_any: &mut dyn Any = state;
977 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(WIDTH, 1000.0));
978 w.event(
979 &mut ctx,
980 &InputEvent::Pointer(frust::authoring::PointerEvent {
981 phase,
982 position: Point::new(x, y),
983 button: frust::authoring::PointerButton::Primary,
984 }),
985 )
986 }
987
988 fn interactive_stack() -> CardListWidget {
989 let view: CardListView<Pressed> = card_list(
990 (0..3).map(|_| any::<Pressed, _>(frust::SizedBox(Some(10.0), Some(CHILD_HEIGHT)))),
991 )
992 .on_press(|s: &mut Pressed, index| s.indices.push(index));
993 let mut w = build(&view);
994 layout(&mut w);
995 w
996 }
997
998 fn card_y(index: usize) -> f64 {
1000 (card_height() + CARD_LIST_GAP) * index as f64 + card_height() / 2.0
1001 }
1002
1003 #[test]
1004 fn an_interactive_stack_fires_the_released_cards_index() {
1005 let mut w = interactive_stack();
1006 let mut state = Pressed::default();
1007 dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, card_y(1));
1008 assert_eq!(w.armed, Some(1));
1009 dispatch(&mut w, &mut state, PointerPhase::Up, 50.0, card_y(1));
1010 assert_eq!(state.indices, vec![1]);
1011 assert_eq!(w.armed, None);
1012 }
1013
1014 #[test]
1015 fn a_release_over_another_card_fires_nothing() {
1016 let mut w = interactive_stack();
1017 let mut state = Pressed::default();
1018 dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, card_y(0));
1019 dispatch(&mut w, &mut state, PointerPhase::Move, 50.0, card_y(2));
1020 dispatch(&mut w, &mut state, PointerPhase::Up, 50.0, card_y(2));
1021 assert!(state.indices.is_empty());
1022 }
1023
1024 #[test]
1025 fn a_press_in_the_gap_between_cards_is_ignored() {
1026 let mut w = interactive_stack();
1027 let mut state = Pressed::default();
1028 let gap_y = card_height() + CARD_LIST_GAP / 2.0;
1030 assert_eq!(
1031 dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, gap_y),
1032 EventResult::Ignored
1033 );
1034 assert_eq!(w.armed, None);
1035 }
1036
1037 #[test]
1038 fn a_secondary_press_never_arms_or_fires() {
1039 let mut w = interactive_stack();
1040 let mut state = Pressed::default();
1041 let state_any: &mut dyn Any = &mut state;
1042 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(WIDTH, 1000.0));
1043 let result = w.event(
1044 &mut ctx,
1045 &InputEvent::Pointer(frust::authoring::PointerEvent {
1046 phase: PointerPhase::Down,
1047 position: Point::new(50.0, card_y(0)),
1048 button: frust::authoring::PointerButton::Secondary,
1049 }),
1050 );
1051 assert_eq!(result, EventResult::Ignored);
1052 assert_eq!(w.armed, None);
1053 }
1054
1055 #[test]
1056 fn a_cancel_clears_the_armed_card_without_firing() {
1057 let mut w = interactive_stack();
1058 let mut state = Pressed::default();
1059 dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, card_y(0));
1060 dispatch(&mut w, &mut state, PointerPhase::Cancel, 50.0, card_y(0));
1061 assert_eq!(w.armed, None);
1062 dispatch(&mut w, &mut state, PointerPhase::Up, 50.0, card_y(0));
1063 assert!(state.indices.is_empty());
1064 }
1065
1066 #[test]
1067 fn a_disabled_stack_ignores_every_pointer_phase() {
1068 let view: CardListView<Pressed> = card_list(
1069 (0..2).map(|_| any::<Pressed, _>(frust::SizedBox(Some(10.0), Some(CHILD_HEIGHT)))),
1070 )
1071 .on_press(|s: &mut Pressed, index| s.indices.push(index))
1072 .enabled(false);
1073 let mut w = build(&view);
1074 layout(&mut w);
1075 let mut state = Pressed::default();
1076 assert_eq!(
1077 dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, card_y(0)),
1078 EventResult::Ignored
1079 );
1080 dispatch(&mut w, &mut state, PointerPhase::Up, 50.0, card_y(0));
1081 assert!(state.indices.is_empty());
1082 assert_eq!(w.armed, None);
1083 }
1084
1085 #[test]
1086 fn the_pressed_card_paints_its_overlay_at_its_own_radii() {
1087 let mut w = interactive_stack();
1088 let mut state = Pressed::default();
1089 dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, card_y(2));
1090 let rec = paint(&mut w, None);
1091 let overlays = rec.overlays();
1092 assert_eq!(overlays.len(), 1, "only the pressed card tints");
1093 assert_eq!(overlays[0].0.y, w.cards[2].0);
1094 assert_eq!(
1095 overlays[0].1,
1096 card_radii(
1097 CardPosition::Last,
1098 CARD_LIST_OUTER_RADIUS,
1099 CARD_LIST_INNER_RADIUS
1100 )
1101 );
1102 assert_eq!(overlays[0].2, frust::authoring::PRESSED_OPACITY);
1103 }
1104
1105 #[test]
1106 fn a_non_interactive_stack_routes_events_into_its_children() {
1107 #[derive(Default)]
1110 struct Counter {
1111 presses: u32,
1112 }
1113 let view: CardListView<Counter> = card_list_items(vec![
1114 crate::list_item::list_item::<Counter>("row")
1115 .on_press(|s: &mut Counter| s.presses += 1),
1116 ]);
1117 let mut w = build(&view);
1118 layout(&mut w);
1119 let mut state = Counter::default();
1120 let row_y = CARD_LIST_ITEM_PADDING + 5.0;
1121 assert_eq!(
1122 dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, row_y),
1123 EventResult::Handled,
1124 "the row itself takes the press"
1125 );
1126 dispatch(&mut w, &mut state, PointerPhase::Up, 50.0, row_y);
1127 assert_eq!(state.presses, 1);
1128 assert_eq!(w.armed, None, "the stack armed nothing of its own");
1129 }
1130
1131 #[test]
1132 fn card_list_items_hosts_rows_that_paint_no_surface_of_their_own() {
1133 let view: CardListView<()> = card_list_items(vec![
1136 crate::list_item::list_item::<()>("a"),
1137 crate::list_item::list_item::<()>("b"),
1138 ]);
1139 let mut w = build(&view);
1140 layout(&mut w);
1141 let rec = paint(&mut w, None);
1142 assert_eq!(
1143 rec.rrects.len(),
1144 2,
1145 "two card fills, and no row-owned surface underneath them"
1146 );
1147 }
1148
1149 #[test]
1152 fn semantics_is_a_list_container_forwarding_its_rows() {
1153 fn logic(_s: &mut ()) -> CardListView<()> {
1154 card_list_items(vec![
1155 crate::list_item::list_item::<()>("a"),
1156 crate::list_item::list_item::<()>("b"),
1157 ])
1158 }
1159 let mut root: frust_core::RenderRoot<(), CardListView<()>> = frust_core::RenderRoot::new();
1160 let mut state = ();
1161 root.rebuild(&mut logic, &mut state);
1162 let mut tcx = TextContext::new();
1163 root.layout_with_text(Size::new(WIDTH, 400.0), &mut tcx as &mut dyn Any);
1164 let update = root.semantics();
1165 let (_, list) = update
1166 .nodes
1167 .iter()
1168 .find(|(_, n)| n.role() == Role::List)
1169 .expect("the stack contributes a Role::List node");
1170 assert_eq!(list.children().len(), 2, "both rows are semantics children");
1171 assert_eq!(
1172 update
1173 .nodes
1174 .iter()
1175 .filter(|(_, n)| n.role() == Role::ListItem)
1176 .count(),
1177 2
1178 );
1179 }
1180
1181 #[test]
1182 fn semantics_an_interactive_stack_carries_click_and_a_disabled_one_reports_disabled() {
1183 fn interactive(_s: &mut ()) -> CardListView<()> {
1184 card_list(vec![leaf_any(10.0, CHILD_HEIGHT)]).on_press(|_: &mut (), _| {})
1185 }
1186 let mut root: frust_core::RenderRoot<(), CardListView<()>> = frust_core::RenderRoot::new();
1187 let mut state = ();
1188 root.rebuild(&mut interactive, &mut state);
1189 let mut tcx = TextContext::new();
1190 root.layout_with_text(Size::new(WIDTH, 400.0), &mut tcx as &mut dyn Any);
1191 let update = root.semantics();
1192 let (_, list) = update
1193 .nodes
1194 .iter()
1195 .find(|(_, n)| n.role() == Role::List)
1196 .expect("list node");
1197 assert!(list.supports_action(Action::Click));
1198 assert!(!list.is_disabled());
1199
1200 fn disabled(_s: &mut ()) -> CardListView<()> {
1201 card_list(vec![leaf_any(10.0, CHILD_HEIGHT)])
1202 .on_press(|_: &mut (), _| {})
1203 .enabled(false)
1204 }
1205 let mut root: frust_core::RenderRoot<(), CardListView<()>> = frust_core::RenderRoot::new();
1206 root.rebuild(&mut disabled, &mut state);
1207 root.layout_with_text(Size::new(WIDTH, 400.0), &mut tcx as &mut dyn Any);
1208 let update = root.semantics();
1209 let (_, list) = update
1210 .nodes
1211 .iter()
1212 .find(|(_, n)| n.role() == Role::List)
1213 .expect("list node");
1214 assert!(list.is_disabled());
1215 assert!(!list.supports_action(Action::Click));
1216 }
1217
1218 struct HoverHarness {
1225 root: frust_core::RenderRoot<Pressed, CardListView<Pressed>>,
1226 state: Pressed,
1227 tcx: TextContext,
1228 enabled: bool,
1229 }
1230
1231 impl HoverHarness {
1232 fn new(enabled: bool) -> Self {
1233 let mut h = HoverHarness {
1234 root: frust_core::RenderRoot::new(),
1235 state: Pressed::default(),
1236 tcx: TextContext::new(),
1237 enabled,
1238 };
1239 let enabled_flag = enabled;
1240 let mut app =
1241 move |_s: &mut Pressed| {
1242 card_list((0..3).map(|_| {
1243 any::<Pressed, _>(frust::SizedBox(Some(10.0), Some(CHILD_HEIGHT)))
1244 }))
1245 .on_press(|s: &mut Pressed, index| s.indices.push(index))
1246 .enabled(enabled_flag)
1247 };
1248 h.root.rebuild(&mut app, &mut h.state);
1249 h.root
1250 .layout_with_text(Size::new(WIDTH, 400.0), &mut h.tcx as &mut dyn Any);
1251 h
1252 }
1253
1254 fn dispatch(&mut self, phase: PointerPhase, x: f64, y: f64) {
1255 self.root.event(
1256 &mut self.state,
1257 &InputEvent::Pointer(frust::authoring::PointerEvent {
1258 phase,
1259 position: Point::new(x, y),
1260 button: frust::authoring::PointerButton::Primary,
1261 }),
1262 );
1263 }
1264
1265 fn overlays(&mut self) -> Vec<(usize, f32)> {
1267 let mut rec = Recorder::default();
1268 self.root.paint(&mut rec, frust::FrameTime::ZERO);
1269 rec.overlays()
1270 .iter()
1271 .map(|(origin, _, alpha)| {
1272 let index = (origin.y / (card_height() + CARD_LIST_GAP)).round() as usize;
1273 (index, *alpha)
1274 })
1275 .collect()
1276 }
1277
1278 fn rect_count(&mut self) -> usize {
1282 let mut rec = Recorder::default();
1283 self.root.paint(&mut rec, frust::FrameTime::ZERO);
1284 rec.rrects.len()
1285 }
1286 }
1287
1288 #[test]
1289 fn hovering_a_card_tints_that_card_only() {
1290 let mut h = HoverHarness::new(true);
1291 assert!(h.overlays().is_empty(), "no overlay at rest");
1292
1293 h.dispatch(PointerPhase::Move, 50.0, card_y(1));
1294 assert_eq!(
1295 h.overlays(),
1296 vec![(1, crate::interaction::HOVER_OPACITY)],
1297 "the hovered card tints at the M3E hover opacity"
1298 );
1299
1300 h.dispatch(PointerPhase::Move, 50.0, card_y(2));
1301 assert_eq!(h.overlays(), vec![(2, crate::interaction::HOVER_OPACITY)]);
1302
1303 h.dispatch(PointerPhase::Move, 50.0, 390.0);
1305 assert!(h.overlays().is_empty());
1306 }
1307
1308 #[test]
1309 fn a_disabled_stack_never_tints_and_never_fires() {
1310 let mut h = HoverHarness::new(false);
1311 h.dispatch(PointerPhase::Move, 50.0, card_y(0));
1312 assert_eq!(
1313 h.rect_count(),
1314 3,
1315 "three dimmed card fills and no state layer: a disabled stack claims no hover"
1316 );
1317 h.dispatch(PointerPhase::Down, 50.0, card_y(0));
1318 h.dispatch(PointerPhase::Up, 50.0, card_y(0));
1319 assert!(h.state.indices.is_empty());
1320 assert!(!h.enabled);
1321 }
1322}