1use std::rc::Rc;
161use std::time::Duration;
162
163use frust::Theme;
164use frust::authoring::{Action, Role};
165use frust::authoring::{
166 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
167 LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
168};
169use frust::{AnimationController, Tween};
170use kurbo::{Point, RoundedRect, Shape, Size};
171use peniko::{Brush, Color};
172
173use crate::interaction::{
174 DISABLED_CONTAINER_OPACITY, HapticSignal, InteractionState, MaterialHaptics, PressSpringId,
175};
176
177use super::press::presses;
178
179const CARD_PADDING: f64 = 16.0;
181const RADIUS: f64 = 12.0;
184const STROKE_WIDTH: f64 = 1.0;
186const STROKE_TOLERANCE: f64 = 0.1;
190
191const ELEVATED_CONTAINER: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
194const FILLED_CONTAINER: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
197const OUTLINED_CONTAINER: Color = Color::from_rgb8(0xFE, 0xF7, 0xFF);
200const OUTLINE_VARIANT: Color = Color::from_rgb8(0xCA, 0xC4, 0xD0);
203const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
206
207const FALLBACK_SHADOW_Y_OFFSET: f64 = 1.5;
211const FALLBACK_SHADOW_BLUR: f64 = 1.0;
214const FALLBACK_HOVER_SHADOW_Y_OFFSET: f64 = 2.5;
218const FALLBACK_HOVER_SHADOW_BLUR: f64 = 3.0;
221const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
225
226const ELEVATION_RETARGET_VELOCITY: f64 = 1e-3;
232
233#[derive(Clone, Copy, Debug, PartialEq, Eq)]
235pub enum CardVariant {
236 Elevated,
237 Filled,
238 Outlined,
239}
240
241fn with_alpha(color: Color, alpha: f32) -> Color {
244 let c = color.components;
245 Color::new([c[0], c[1], c[2], alpha])
246}
247
248fn resolve_container(theme: Option<&Theme>, variant: CardVariant, disabled: bool) -> Color {
256 if disabled {
257 return with_alpha(resolve_content_color(theme), DISABLED_CONTAINER_OPACITY);
258 }
259 match theme {
260 Some(theme) => {
261 let scheme = theme.scheme();
262 match variant {
263 CardVariant::Elevated => scheme.surface_container_low,
264 CardVariant::Filled => scheme.surface_container_highest,
265 CardVariant::Outlined => scheme.surface,
266 }
267 }
268 None => match variant {
269 CardVariant::Elevated => ELEVATED_CONTAINER,
270 CardVariant::Filled => FILLED_CONTAINER,
271 CardVariant::Outlined => OUTLINED_CONTAINER,
272 },
273 }
274}
275
276fn resolve_radius(theme: Option<&Theme>) -> f64 {
279 match theme {
280 Some(theme) => theme.shape.medium,
281 None => RADIUS,
282 }
283}
284
285fn resolve_outline(theme: Option<&Theme>, disabled: bool) -> Color {
291 if disabled {
292 return with_alpha(resolve_content_color(theme), DISABLED_CONTAINER_OPACITY);
293 }
294 match theme {
295 Some(theme) => theme.scheme().outline_variant,
296 None => OUTLINE_VARIANT,
297 }
298}
299
300fn resolve_content_color(theme: Option<&Theme>) -> Color {
303 match theme {
304 Some(theme) => theme.scheme().on_surface,
305 None => ON_SURFACE,
306 }
307}
308
309fn resolve_elevation_endpoints(theme: Option<&Theme>) -> ((f64, f64), (f64, f64)) {
317 match theme {
318 Some(theme) => {
319 let rest = theme.elevation.level1.shadow(theme.brightness);
320 let hover = theme.elevation.level2.shadow(theme.brightness);
321 (
322 (rest.blur_std_dev, rest.y_offset),
323 (hover.blur_std_dev, hover.y_offset),
324 )
325 }
326 None => (
327 (FALLBACK_SHADOW_BLUR, FALLBACK_SHADOW_Y_OFFSET),
328 (FALLBACK_HOVER_SHADOW_BLUR, FALLBACK_HOVER_SHADOW_Y_OFFSET),
329 ),
330 }
331}
332
333fn resolve_shadow_color(theme: Option<&Theme>) -> Color {
338 match theme {
339 Some(theme) => {
340 let alpha = theme.elevation.level1.shadow(theme.brightness).color_alpha;
341 with_alpha(theme.scheme().shadow, alpha)
342 }
343 None => FALLBACK_SHADOW_COLOR,
344 }
345}
346
347fn inside(pos: Point, size: Size) -> bool {
348 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
349}
350
351type OnPress<State> = Rc<dyn Fn(&mut State)>;
353
354pub struct CardView<State: 'static> {
356 variant: CardVariant,
357 child: AnyView<State>,
358 on_press: Option<OnPress<State>>,
359 enabled: bool,
360 haptic: HapticSignal,
361}
362
363pub fn card<State: 'static, V: View<State>>(variant: CardVariant, child: V) -> CardView<State> {
366 CardView {
367 variant,
368 child: any(child),
369 on_press: None,
370 enabled: true,
371 haptic: HapticSignal::None,
372 }
373}
374
375pub fn elevated_card<State: 'static, V: View<State>>(child: V) -> CardView<State> {
378 card(CardVariant::Elevated, child)
379}
380
381pub fn filled_card<State: 'static, V: View<State>>(child: V) -> CardView<State> {
383 card(CardVariant::Filled, child)
384}
385
386pub fn outlined_card<State: 'static, V: View<State>>(child: V) -> CardView<State> {
388 card(CardVariant::Outlined, child)
389}
390
391impl<State: 'static> CardView<State> {
392 pub fn on_press<F: Fn(&mut State) + 'static>(mut self, on_press: F) -> Self {
395 self.on_press = Some(Rc::new(on_press));
396 self
397 }
398
399 pub fn enabled(mut self, enabled: bool) -> Self {
407 self.enabled = enabled;
408 self
409 }
410
411 pub fn haptic(mut self, haptic: HapticSignal) -> Self {
416 self.haptic = haptic;
417 self
418 }
419}
420
421pub struct CardWidget {
423 variant: CardVariant,
424 child: ChildPod,
425 interactive: bool,
426 enabled: bool,
427 haptic: HapticSignal,
428 captured: bool,
431 state: InteractionState,
432 elevation_anim: AnimationController,
438 elevation_target: bool,
443 on_press: Option<frust::authoring::ErasedCallback>,
444}
445
446impl<State: 'static> View<State> for CardView<State> {
447 type Element = CardWidget;
448
449 fn build(&self, ctx: &mut BuildCtx<'_>) -> CardWidget {
450 CardWidget {
451 variant: self.variant,
452 child: frust::authoring::build_child(&self.child, ctx),
453 interactive: self.on_press.is_some(),
454 enabled: self.enabled,
455 haptic: self.haptic,
456 captured: false,
457 state: InteractionState::new(),
458 elevation_anim: AnimationController::new(Duration::ZERO),
459 elevation_target: false,
460 on_press: self.on_press.as_ref().map(frust::authoring::erase_callback),
461 }
462 }
463
464 fn rebuild(
465 &self,
466 prev: &Self,
467 element: &mut CardWidget,
468 ctx: &mut BuildCtx<'_>,
469 ) -> ChangeFlags {
470 let mut flags = ChangeFlags::NONE;
471 if prev.variant != self.variant {
472 element.variant = self.variant;
473 flags |= ChangeFlags::PAINT;
474 }
475 let now_interactive = self.on_press.is_some();
476 if element.interactive != now_interactive {
477 element.interactive = now_interactive;
478 if !now_interactive {
481 element.captured = false;
482 element.state.set_pressed(false);
483 element.state.set_hovered(false);
484 }
485 flags |= ChangeFlags::PAINT;
486 }
487 if prev.enabled != self.enabled {
488 element.enabled = self.enabled;
489 if !self.enabled && element.interactive {
490 element.captured = false;
494 element.state.set_pressed(false);
495 element.state.set_hovered(false);
496 }
497 if element.interactive {
498 flags |= ChangeFlags::PAINT;
499 }
500 }
501 element.haptic = self.haptic;
502 element.on_press = self.on_press.as_ref().map(frust::authoring::erase_callback);
503 flags |= frust::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
504 flags
505 }
506
507 fn teardown(&self, element: &mut CardWidget, ctx: &mut BuildCtx<'_>) {
508 frust::authoring::teardown_child(&self.child, &mut element.child, ctx);
509 }
510}
511
512impl Widget for CardWidget {
513 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
514 let inset = CARD_PADDING * 2.0;
515 let inner_max = Size::new(
516 (bc.max().width - inset).max(0.0),
517 (bc.max().height - inset).max(0.0),
518 );
519 let inner_min = Size::new(
520 (bc.min().width - inset).max(0.0),
521 (bc.min().height - inset).max(0.0),
522 );
523 let child_size = self
524 .child
525 .layout_child(ctx, &BoxConstraints::new(inner_min, inner_max));
526 self.child
527 .set_origin(Point::new(CARD_PADDING, CARD_PADDING));
528 bc.constrain(Size::new(
529 child_size.width + inset,
530 child_size.height + inset,
531 ))
532 }
533
534 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
535 let theme = Theme::from_paint_ctx(ctx);
539 let disabled = self.interactive && !self.enabled;
540 let container = resolve_container(theme, self.variant, disabled);
541 let radius = resolve_radius(theme);
542 let hovered = self.interactive && self.enabled && ctx.is_hovered();
547 let (elevation_rest, elevation_hover) = resolve_elevation_endpoints(theme);
548 let shadow_color = resolve_shadow_color(theme);
549 let outline =
550 (self.variant == CardVariant::Outlined).then(|| resolve_outline(theme, disabled));
551 let content_color = resolve_content_color(theme);
555
556 self.state.set_hovered(hovered);
557
558 let o = ctx.origin();
559 let size = ctx.size();
560
561 if self.variant == CardVariant::Elevated {
562 if hovered != self.elevation_target {
563 let velocity = if hovered {
564 ELEVATION_RETARGET_VELOCITY
565 } else {
566 -ELEVATION_RETARGET_VELOCITY
567 };
568 self.elevation_anim
569 .fling(velocity, PressSpringId::DefaultEffects.resolve());
570 self.elevation_target = hovered;
571 }
572 if self.elevation_anim.advance(ctx.frame_time()) {
573 ctx.request_frame();
574 }
575 let t = self.elevation_anim.value_clamped();
576 let blur = Tween::new(elevation_rest.0, elevation_hover.0).lerp(t);
577 let y_offset = Tween::new(elevation_rest.1, elevation_hover.1).lerp(t);
578 scene.draw_shadow(
579 Point::new(o.x, o.y + y_offset),
580 size,
581 radius,
582 blur,
583 shadow_color,
584 );
585 }
586
587 scene.fill_rounded_rect(o, size, radius, container);
588
589 if let Some(outline) = outline {
590 let half = STROKE_WIDTH / 2.0;
593 let rr = RoundedRect::new(
594 half,
595 half,
596 size.width - half,
597 size.height - half,
598 (radius - half).max(0.0),
599 );
600 let path = rr.to_path(STROKE_TOLERANCE);
601 scene.stroke_path(o, &path, STROKE_WIDTH, &Brush::Solid(outline));
602 }
603
604 if self.interactive {
605 let opacity = self.state.resolve_opacity();
606 if opacity > 0.0 {
607 scene.fill_rounded_rect(o, size, radius, with_alpha(content_color, opacity));
608 }
609 }
610
611 self.child.paint_child(ctx, scene);
612 }
613
614 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
615 if !self.interactive {
616 return frust::authoring::route_event_single(&mut self.child, ctx, event);
617 }
618 let InputEvent::Pointer(p) = event else {
622 return frust::authoring::route_event_single(&mut self.child, ctx, event);
623 };
624 if !self.enabled {
625 return EventResult::Ignored;
632 }
633 let on_press = self
634 .on_press
635 .as_mut()
636 .expect("on_press is set whenever interactive is true");
637 match p.phase {
638 PointerPhase::Down => {
639 if !presses(p) {
640 return EventResult::Ignored;
641 }
642 self.state.set_pressed(true);
643 self.captured = true;
644 ctx.capture_pointer();
645 ctx.request_redraw();
646 EventResult::Handled
647 }
648 PointerPhase::Move => {
649 if !self.captured {
650 let over = inside(p.position, ctx.size());
655 if over {
656 ctx.claim_hover();
657 }
658 if self.state.set_hovered(over) {
659 ctx.request_redraw();
660 }
661 return EventResult::Ignored;
662 }
663 let inside_now = inside(p.position, ctx.size());
664 if self.state.set_pressed(inside_now) {
665 ctx.request_redraw();
666 }
667 EventResult::Handled
668 }
669 PointerPhase::Up => {
670 if !self.captured {
671 return EventResult::Ignored;
672 }
673 if inside(p.position, ctx.size()) {
674 if self.haptic != HapticSignal::None {
675 MaterialHaptics::fire(self.haptic);
676 }
677 (on_press)(ctx);
678 }
679 self.state.set_pressed(false);
680 self.captured = false;
681 ctx.request_redraw();
682 EventResult::Handled
683 }
684 PointerPhase::Cancel => {
685 if !self.captured {
686 return EventResult::Ignored;
687 }
688 self.state.set_pressed(false);
689 self.captured = false;
690 ctx.request_redraw();
691 EventResult::Handled
692 }
693 }
694 }
695
696 fn semantics(&self, ctx: &mut SemanticsCtx) {
697 if self.interactive {
698 ctx.push_container(
699 Role::Button,
700 |node| {
701 if self.enabled {
702 node.add_action(Action::Click);
703 } else {
704 node.set_disabled();
705 }
706 },
707 |ctx| {
708 self.child.semantics_child(ctx);
709 },
710 );
711 } else {
712 ctx.push_container(
713 Role::GenericContainer,
714 |_| {},
715 |ctx| {
716 self.child.semantics_child(ctx);
717 },
718 );
719 }
720 }
721
722 frust::authoring::visit_children!(child);
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use frust::FrameTime;
729 use frust_widgets::test_support::leaf_any;
730 use std::any::Any;
731
732 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
733 InputEvent::Pointer(frust::authoring::PointerEvent {
734 phase,
735 position: Point::new(x, y),
736 button: frust::authoring::PointerButton::Primary,
737 })
738 }
739
740 fn ft_secs(s: f64) -> FrameTime {
741 FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
742 }
743
744 #[derive(Default)]
748 struct Recorder {
749 rrects: Vec<(Point, Size, f64, Color)>,
750 shadows: Vec<(Point, Size, f64, f64, Color)>,
751 strokes: Vec<(Point, f64, Color)>,
752 }
753
754 impl PaintScene for Recorder {
755 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
756 fn draw_text(&mut self, _o: Point, _t: &str) {}
757 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
758 self.rrects.push((o, s, radius, color));
759 }
760 fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
761 self.shadows.push((o, s, radius, std_dev, color));
762 }
763 fn stroke_path(
764 &mut self,
765 origin: Point,
766 _path: &kurbo::BezPath,
767 width: f64,
768 brush: &Brush,
769 ) {
770 if let Brush::Solid(color) = brush {
771 self.strokes.push((origin, width, *color));
772 }
773 }
774 }
775
776 fn build<S: 'static>(view: &CardView<S>) -> CardWidget {
777 let mut counter = 0u64;
778 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
779 }
780
781 fn paint(w: &mut CardWidget, size: Size, theme: Option<&Theme>) -> Recorder {
782 let mut rec = Recorder::default();
783 let mut ctx = match theme {
784 Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
785 None => PaintCtx::new(Point::ZERO, size),
786 };
787 w.paint(&mut ctx, &mut rec);
788 rec
789 }
790
791 #[test]
792 fn layout_insets_child_by_card_padding() {
793 let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
794 let mut w = build(&view);
795 let mut lctx = LayoutCtx::new();
796 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
797 assert_eq!(size, Size::new(40.0 + 32.0, 20.0 + 32.0));
798 assert_eq!(w.child.origin(), Point::new(CARD_PADDING, CARD_PADDING));
799 }
800
801 #[test]
802 fn elevated_unthemed_paints_container_and_shadow() {
803 let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
804 let mut w = build(&view);
805 let rec = paint(&mut w, Size::new(100.0, 60.0), None);
806 assert_eq!(rec.rrects[0].3, ELEVATED_CONTAINER);
807 assert_eq!(rec.rrects[0].2, RADIUS);
808 assert_eq!(rec.shadows.len(), 1);
809 assert_eq!(rec.shadows[0].3, FALLBACK_SHADOW_BLUR);
810 assert!(rec.strokes.is_empty(), "elevated card has no stroke");
811 }
812
813 #[test]
814 fn filled_unthemed_paints_container_with_no_shadow() {
815 let view: CardView<()> = filled_card(leaf_any(40.0, 20.0));
816 let mut w = build(&view);
817 let rec = paint(&mut w, Size::new(100.0, 60.0), None);
818 assert_eq!(rec.rrects[0].3, FILLED_CONTAINER);
819 assert!(rec.shadows.is_empty(), "filled card paints no shadow");
820 assert!(rec.strokes.is_empty(), "filled card has no stroke");
821 }
822
823 #[test]
824 fn outlined_unthemed_paints_container_and_stroke_with_no_shadow() {
825 let view: CardView<()> = outlined_card(leaf_any(40.0, 20.0));
826 let mut w = build(&view);
827 let rec = paint(&mut w, Size::new(100.0, 60.0), None);
828 assert_eq!(rec.rrects[0].3, OUTLINED_CONTAINER);
829 assert!(rec.shadows.is_empty(), "outlined card paints no shadow");
830 assert_eq!(rec.strokes.len(), 1);
831 assert_eq!(rec.strokes[0].1, STROKE_WIDTH);
832 assert_eq!(rec.strokes[0].2, OUTLINE_VARIANT);
833 }
834
835 #[test]
836 fn themed_elevated_paint_shadow_is_unchanged_on_m3_light() {
837 let theme = crate::baseline();
844 assert_eq!(theme.brightness, frust::Brightness::Light);
845 let level1 = theme.elevation.level1;
846
847 let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
848 let mut w = build(&view);
849 let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
850
851 assert_eq!(rec.shadows.len(), 1);
852 assert_eq!(rec.shadows[0].3, level1.shadow_light.blur_std_dev);
853 assert_eq!(
854 rec.shadows[0].4.components[3],
855 level1.shadow_light.color_alpha
856 );
857 assert_eq!(
860 rec.shadows[0].3,
861 level1.shadow(theme.brightness).blur_std_dev
862 );
863 }
864
865 #[test]
866 fn themed_paint_resolves_r11_tokens() {
867 let theme = crate::baseline();
868 let scheme = theme.scheme();
869
870 let elevated: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
871 let mut w = build(&elevated);
872 let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
873 assert_eq!(rec.rrects[0].3, scheme.surface_container_low);
874 assert_eq!(rec.rrects[0].2, theme.shape.medium);
875
876 let filled: CardView<()> = filled_card(leaf_any(40.0, 20.0));
877 let mut w = build(&filled);
878 let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
879 assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
880
881 let outlined: CardView<()> = outlined_card(leaf_any(40.0, 20.0));
882 let mut w = build(&outlined);
883 let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
884 assert_eq!(rec.rrects[0].3, scheme.surface);
885 assert_eq!(rec.strokes[0].2, scheme.outline_variant);
886 }
887
888 #[test]
891 fn disabled_interactive_card_dims_container_uniformly_across_variants() {
892 let theme = crate::baseline();
893 let expected = with_alpha(theme.scheme().on_surface, DISABLED_CONTAINER_OPACITY);
894 for variant in [
895 CardVariant::Elevated,
896 CardVariant::Filled,
897 CardVariant::Outlined,
898 ] {
899 let view: CardView<()> = card(variant, leaf_any(40.0, 20.0))
900 .on_press(|_: &mut ()| {})
901 .enabled(false);
902 let mut w = build(&view);
903 let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
904 assert_eq!(
905 rec.rrects[0].3, expected,
906 "{variant:?} container dims while disabled"
907 );
908 }
909 }
910
911 #[test]
912 fn disabled_interactive_outlined_card_dims_its_outline_too() {
913 let theme = crate::baseline();
914 let expected = with_alpha(theme.scheme().on_surface, DISABLED_CONTAINER_OPACITY);
915 let view: CardView<()> = outlined_card(leaf_any(40.0, 20.0))
916 .on_press(|_: &mut ()| {})
917 .enabled(false);
918 let mut w = build(&view);
919 let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
920 assert_eq!(rec.strokes[0].2, expected);
921 }
922
923 #[test]
924 fn disabled_interactive_elevated_card_still_rests_at_level1_unlifted() {
925 let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0))
926 .on_press(|_: &mut ()| {})
927 .enabled(false);
928 let mut w = build(&view);
929 let rec = paint(&mut w, Size::new(100.0, 60.0), None);
930 assert_eq!(rec.shadows.len(), 1);
931 assert_eq!(
932 rec.shadows[0].3, FALLBACK_SHADOW_BLUR,
933 "never lifts: a disabled card never hovers"
934 );
935 assert!(
936 rec.rrects.get(1).is_none(),
937 "no state-layer overlay while disabled"
938 );
939 }
940
941 #[derive(Default)]
944 struct Counter {
945 presses: u32,
946 }
947
948 fn dispatch<S: 'static>(w: &mut CardWidget, state: &mut S, event: &InputEvent) -> EventResult {
949 let state_any: &mut dyn Any = state;
950 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(200.0, 200.0));
951 w.event(&mut ctx, event)
952 }
953
954 #[test]
955 fn non_interactive_card_forwards_events_to_its_child() {
956 let view: CardView<Counter> =
957 elevated_card(frust::button::<Counter, _>("go", |s: &mut Counter| {
958 s.presses += 1
959 }));
960 let mut w = build(&view);
961 let mut tcx = frust::authoring::text::TextContext::new();
962 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
963 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
964
965 let origin = w.child.origin();
966 let mut state = Counter::default();
967 dispatch(
968 &mut w,
969 &mut state,
970 &ev(PointerPhase::Down, origin.x + 2.0, origin.y + 2.0),
971 );
972 dispatch(
973 &mut w,
974 &mut state,
975 &ev(PointerPhase::Up, origin.x + 2.0, origin.y + 2.0),
976 );
977 assert_eq!(state.presses, 1, "the nested button fires through the card");
978 }
979
980 fn content_stub<S: 'static>() -> AnyView<S> {
987 any::<S, _>(frust::text("content"))
988 }
989
990 #[test]
991 fn interactive_card_fires_on_up_inside_without_forwarding_to_child() {
992 let view: CardView<Counter> =
993 filled_card(content_stub::<Counter>()).on_press(|s: &mut Counter| s.presses += 1);
994 let mut w = build(&view);
995 let mut state = Counter::default();
996 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
997 assert!(w.captured);
998 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
999 assert_eq!(state.presses, 1);
1000 }
1001
1002 #[test]
1003 fn interactive_card_up_outside_does_not_fire() {
1004 let view: CardView<Counter> =
1005 filled_card(content_stub::<Counter>()).on_press(|s: &mut Counter| s.presses += 1);
1006 let mut w = build(&view);
1007 let mut state = Counter::default();
1008 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1009 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 500.0));
1010 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 500.0));
1011 assert_eq!(state.presses, 0);
1012 }
1013
1014 #[test]
1015 fn interactive_card_cancel_clears_armed_state() {
1016 let view: CardView<Counter> =
1017 filled_card(content_stub::<Counter>()).on_press(|s: &mut Counter| s.presses += 1);
1018 let mut w = build(&view);
1019 let mut state = Counter::default();
1020 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1021 assert!(w.captured);
1022 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
1023 assert!(!w.captured);
1024 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1025 assert_eq!(state.presses, 0);
1026 }
1027
1028 #[test]
1029 fn disabled_interactive_card_ignores_every_pointer_phase() {
1030 let view: CardView<Counter> = filled_card(content_stub::<Counter>())
1031 .on_press(|s: &mut Counter| s.presses += 1)
1032 .enabled(false);
1033 let mut w = build(&view);
1034 let mut state = Counter::default();
1035 assert_eq!(
1036 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0)),
1037 EventResult::Ignored
1038 );
1039 assert!(!w.captured, "a disabled card never captures");
1040 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1041 assert_eq!(state.presses, 0, "and never fires");
1042 }
1043
1044 struct FocusConsumer;
1048
1049 impl FocusConsumer {
1050 fn new() -> Self {
1051 FocusConsumer
1052 }
1053 }
1054
1055 struct FocusConsumerWidget;
1056
1057 impl View<Counter> for FocusConsumer {
1058 type Element = FocusConsumerWidget;
1059 fn build(&self, _ctx: &mut BuildCtx<'_>) -> FocusConsumerWidget {
1060 FocusConsumerWidget
1061 }
1062 fn rebuild(
1063 &self,
1064 _prev: &Self,
1065 _element: &mut FocusConsumerWidget,
1066 _ctx: &mut BuildCtx<'_>,
1067 ) -> frust::authoring::ChangeFlags {
1068 frust::authoring::ChangeFlags::NONE
1069 }
1070 }
1071
1072 impl Widget for FocusConsumerWidget {
1073 fn layout(&mut self, _ctx: &mut frust::authoring::LayoutCtx, bc: &BoxConstraints) -> Size {
1074 bc.max()
1075 }
1076 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1077 fn event(&mut self, _ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1078 if let InputEvent::Key(_) = event {
1081 return EventResult::Handled;
1082 }
1083 EventResult::Ignored
1084 }
1085 }
1086
1087 #[test]
1088 fn interactive_card_forwards_key_events_to_focused_child() {
1089 let view: CardView<Counter> =
1090 filled_card(FocusConsumer::new()).on_press(|s: &mut Counter| s.presses += 1);
1091 let mut w = build(&view);
1092 let mut state = Counter::default();
1093
1094 w.child.set_focused(true);
1098
1099 let key_event = InputEvent::Key(frust::authoring::KeyEvent {
1101 key: frust::authoring::Key::Named(frust::authoring::NamedKey::Backspace),
1102 modifiers: frust::authoring::Modifiers::default(),
1103 repeat: false,
1104 });
1105 let result = dispatch(&mut w, &mut state, &key_event);
1106
1107 assert_eq!(
1109 result,
1110 EventResult::Handled,
1111 "key event must be forwarded to child"
1112 );
1113
1114 assert_eq!(
1116 state.presses, 0,
1117 "card press callback does not fire on key event"
1118 );
1119 }
1120
1121 #[test]
1122 fn semantics_interactive_card_is_a_button_container_forwarding_child() {
1123 fn logic(_s: &mut ()) -> CardView<()> {
1127 filled_card(frust::text("body")).on_press(|_s: &mut ()| {})
1128 }
1129 let mut root: frust_core::RenderRoot<(), CardView<()>> = frust_core::RenderRoot::new();
1130 let mut state = ();
1131 root.rebuild(&mut logic, &mut state);
1132 let mut tcx = frust::authoring::text::TextContext::new();
1133 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1134 let update = root.semantics();
1135 let (_, node) = update
1136 .nodes
1137 .iter()
1138 .find(|(_, n)| n.role() == Role::Button)
1139 .expect("interactive card contributes a Role::Button container node");
1140 assert!(
1141 !node.children().is_empty(),
1142 "the child is a semantics child"
1143 );
1144 assert!(!node.is_disabled());
1145 }
1146
1147 #[test]
1148 fn semantics_disabled_interactive_card_reports_disabled_and_no_click_action() {
1149 fn logic(_s: &mut ()) -> CardView<()> {
1150 filled_card(frust::text("body"))
1151 .on_press(|_s: &mut ()| {})
1152 .enabled(false)
1153 }
1154 let mut root: frust_core::RenderRoot<(), CardView<()>> = frust_core::RenderRoot::new();
1155 let mut state = ();
1156 root.rebuild(&mut logic, &mut state);
1157 let mut tcx = frust::authoring::text::TextContext::new();
1158 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1159 let update = root.semantics();
1160 let (_, node) = update
1161 .nodes
1162 .iter()
1163 .find(|(_, n)| n.role() == Role::Button)
1164 .expect("a disabled interactive card still contributes a Role::Button node");
1165 assert!(node.is_disabled());
1166 }
1167
1168 #[test]
1169 fn semantics_non_interactive_card_is_a_generic_container() {
1170 fn logic(_s: &mut ()) -> CardView<()> {
1171 filled_card(frust::text("body"))
1172 }
1173 let mut root: frust_core::RenderRoot<(), CardView<()>> = frust_core::RenderRoot::new();
1174 let mut state = ();
1175 root.rebuild(&mut logic, &mut state);
1176 let mut tcx = frust::authoring::text::TextContext::new();
1177 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1178 let update = root.semantics();
1179 let (_, node) = update
1180 .nodes
1181 .iter()
1182 .find(|(_, n)| n.role() == Role::GenericContainer)
1183 .expect("non-interactive card contributes a Role::GenericContainer node");
1184 assert!(
1185 !node.children().is_empty(),
1186 "the child is a semantics child"
1187 );
1188 }
1189
1190 const HARNESS_WINDOW: Size = Size::new(300.0, 200.0);
1199
1200 struct Harness {
1201 root: frust_core::RenderRoot<u32, CardView<u32>>,
1202 state: u32,
1203 tcx: frust::authoring::text::TextContext,
1204 variant: CardVariant,
1205 enabled: bool,
1206 }
1207
1208 impl Harness {
1209 fn new(variant: CardVariant, enabled: bool) -> Self {
1210 let mut h = Self {
1211 root: frust_core::RenderRoot::new(),
1212 state: 0,
1213 tcx: frust::authoring::text::TextContext::new(),
1214 variant,
1215 enabled,
1216 };
1217 h.sync();
1218 h
1219 }
1220
1221 fn sync(&mut self) {
1222 let (variant, enabled) = (self.variant, self.enabled);
1223 let mut app = move |_: &mut u32| {
1224 card::<u32, _>(variant, content_stub::<u32>())
1225 .on_press(|s: &mut u32| *s += 1)
1226 .enabled(enabled)
1227 };
1228 self.root.rebuild(&mut app, &mut self.state);
1229 self.root
1230 .layout_with_text(HARNESS_WINDOW, &mut self.tcx as &mut dyn Any);
1231 }
1232
1233 fn dispatch(&mut self, phase: PointerPhase, x: f64, y: f64) {
1234 self.root.event(
1235 &mut self.state,
1236 &InputEvent::Pointer(frust::authoring::PointerEvent {
1237 phase,
1238 position: Point::new(x, y),
1239 button: frust::authoring::PointerButton::Primary,
1240 }),
1241 );
1242 }
1243
1244 fn paint_at(&mut self, ft: FrameTime) -> Recorder {
1245 let mut rec = Recorder::default();
1246 self.root.paint(&mut rec, ft);
1247 rec
1248 }
1249
1250 fn overlay_alpha(&mut self, ft: FrameTime) -> Option<f32> {
1253 let rec = self.paint_at(ft);
1254 rec.rrects
1255 .get(1)
1256 .map(|(_, _, _, color)| color.components[3])
1257 }
1258 }
1259
1260 #[test]
1261 fn hover_lifts_the_elevated_card_shadow_from_level1_to_level2_and_back() {
1262 let mut h = Harness::new(CardVariant::Elevated, true);
1263 let rest = h.paint_at(FrameTime::ZERO);
1264 assert_eq!(rest.shadows[0].3, FALLBACK_SHADOW_BLUR, "rests at level1");
1265 assert_eq!(rest.shadows[0].0, Point::new(0.0, FALLBACK_SHADOW_Y_OFFSET));
1266
1267 h.dispatch(PointerPhase::Move, 10.0, 10.0);
1268 h.paint_at(ft_secs(0.0));
1272 let settled = h.paint_at(ft_secs(1.0));
1273 assert_eq!(
1274 settled.shadows[0].3, FALLBACK_HOVER_SHADOW_BLUR,
1275 "settles at level2's blur once the hover-lift spring settles"
1276 );
1277 assert_eq!(
1278 settled.shadows[0].0,
1279 Point::new(0.0, FALLBACK_HOVER_SHADOW_Y_OFFSET)
1280 );
1281
1282 h.dispatch(PointerPhase::Move, 290.0, 190.0);
1283 h.paint_at(ft_secs(1.0));
1284 let dropped = h.paint_at(ft_secs(2.0));
1285 assert_eq!(
1286 dropped.shadows[0].3, FALLBACK_SHADOW_BLUR,
1287 "drops back to level1 once unhovered"
1288 );
1289 }
1290
1291 #[test]
1292 fn hover_never_lifts_a_disabled_interactive_card() {
1293 let mut h = Harness::new(CardVariant::Elevated, false);
1294 h.dispatch(PointerPhase::Move, 10.0, 10.0);
1295 let rec = h.paint_at(ft_secs(1.0));
1296 assert_eq!(
1297 rec.shadows[0].3, FALLBACK_SHADOW_BLUR,
1298 "a disabled card never reacts to hover (the radio-hover-regression fix)"
1299 );
1300 h.dispatch(PointerPhase::Down, 10.0, 10.0);
1301 h.dispatch(PointerPhase::Up, 10.0, 10.0);
1302 assert_eq!(h.state, 0, "and never fires either");
1303 }
1304
1305 #[test]
1306 fn hovering_paints_the_hover_state_layer_and_pressing_outranks_it() {
1307 let mut h = Harness::new(CardVariant::Filled, true);
1308 assert_eq!(h.overlay_alpha(FrameTime::ZERO), None, "no overlay at rest");
1309
1310 h.dispatch(PointerPhase::Move, 10.0, 10.0);
1311 assert_eq!(
1312 h.overlay_alpha(ft_secs(0.0)),
1313 Some(crate::interaction::HOVER_OPACITY),
1314 "the hovered card tints at the M3E hover opacity"
1315 );
1316
1317 h.dispatch(PointerPhase::Down, 10.0, 10.0);
1318 assert_eq!(
1319 h.overlay_alpha(ft_secs(0.0)),
1320 Some(frust::authoring::PRESSED_OPACITY),
1321 "pressed outranks hover in the precedence order"
1322 );
1323
1324 h.dispatch(PointerPhase::Up, 10.0, 10.0);
1325 assert_eq!(h.state, 1, "and the release fired the callback");
1326 }
1327
1328 #[test]
1329 fn disabled_card_never_tints_and_never_fires() {
1330 let mut h = Harness::new(CardVariant::Filled, false);
1331 h.dispatch(PointerPhase::Move, 10.0, 10.0);
1332 assert_eq!(
1333 h.overlay_alpha(ft_secs(0.0)),
1334 None,
1335 "a disabled card claims no hover"
1336 );
1337 h.dispatch(PointerPhase::Down, 10.0, 10.0);
1338 h.dispatch(PointerPhase::Up, 10.0, 10.0);
1339 assert_eq!(h.state, 0);
1340 }
1341}