use std::rc::Rc;
use std::time::Duration;
use frust::Theme;
use frust::authoring::{Action, Role};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
};
use frust::{AnimationController, Tween};
use kurbo::{Point, RoundedRect, Shape, Size};
use peniko::{Brush, Color};
use crate::interaction::{
DISABLED_CONTAINER_OPACITY, HapticSignal, InteractionState, MaterialHaptics, PressSpringId,
};
use super::press::presses;
const CARD_PADDING: f64 = 16.0;
const RADIUS: f64 = 12.0;
const STROKE_WIDTH: f64 = 1.0;
const STROKE_TOLERANCE: f64 = 0.1;
const ELEVATED_CONTAINER: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
const FILLED_CONTAINER: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
const OUTLINED_CONTAINER: Color = Color::from_rgb8(0xFE, 0xF7, 0xFF);
const OUTLINE_VARIANT: Color = Color::from_rgb8(0xCA, 0xC4, 0xD0);
const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const FALLBACK_SHADOW_Y_OFFSET: f64 = 1.5;
const FALLBACK_SHADOW_BLUR: f64 = 1.0;
const FALLBACK_HOVER_SHADOW_Y_OFFSET: f64 = 2.5;
const FALLBACK_HOVER_SHADOW_BLUR: f64 = 3.0;
const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
const ELEVATION_RETARGET_VELOCITY: f64 = 1e-3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CardVariant {
Elevated,
Filled,
Outlined,
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn resolve_container(theme: Option<&Theme>, variant: CardVariant, disabled: bool) -> Color {
if disabled {
return with_alpha(resolve_content_color(theme), DISABLED_CONTAINER_OPACITY);
}
match theme {
Some(theme) => {
let scheme = theme.scheme();
match variant {
CardVariant::Elevated => scheme.surface_container_low,
CardVariant::Filled => scheme.surface_container_highest,
CardVariant::Outlined => scheme.surface,
}
}
None => match variant {
CardVariant::Elevated => ELEVATED_CONTAINER,
CardVariant::Filled => FILLED_CONTAINER,
CardVariant::Outlined => OUTLINED_CONTAINER,
},
}
}
fn resolve_radius(theme: Option<&Theme>) -> f64 {
match theme {
Some(theme) => theme.shape.medium,
None => RADIUS,
}
}
fn resolve_outline(theme: Option<&Theme>, disabled: bool) -> Color {
if disabled {
return with_alpha(resolve_content_color(theme), DISABLED_CONTAINER_OPACITY);
}
match theme {
Some(theme) => theme.scheme().outline_variant,
None => OUTLINE_VARIANT,
}
}
fn resolve_content_color(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().on_surface,
None => ON_SURFACE,
}
}
fn resolve_elevation_endpoints(theme: Option<&Theme>) -> ((f64, f64), (f64, f64)) {
match theme {
Some(theme) => {
let rest = theme.elevation.level1.shadow(theme.brightness);
let hover = theme.elevation.level2.shadow(theme.brightness);
(
(rest.blur_std_dev, rest.y_offset),
(hover.blur_std_dev, hover.y_offset),
)
}
None => (
(FALLBACK_SHADOW_BLUR, FALLBACK_SHADOW_Y_OFFSET),
(FALLBACK_HOVER_SHADOW_BLUR, FALLBACK_HOVER_SHADOW_Y_OFFSET),
),
}
}
fn resolve_shadow_color(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => {
let alpha = theme.elevation.level1.shadow(theme.brightness).color_alpha;
with_alpha(theme.scheme().shadow, alpha)
}
None => FALLBACK_SHADOW_COLOR,
}
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
type OnPress<State> = Rc<dyn Fn(&mut State)>;
pub struct CardView<State: 'static> {
variant: CardVariant,
child: AnyView<State>,
on_press: Option<OnPress<State>>,
enabled: bool,
haptic: HapticSignal,
}
pub fn card<State: 'static, V: View<State>>(variant: CardVariant, child: V) -> CardView<State> {
CardView {
variant,
child: any(child),
on_press: None,
enabled: true,
haptic: HapticSignal::None,
}
}
pub fn elevated_card<State: 'static, V: View<State>>(child: V) -> CardView<State> {
card(CardVariant::Elevated, child)
}
pub fn filled_card<State: 'static, V: View<State>>(child: V) -> CardView<State> {
card(CardVariant::Filled, child)
}
pub fn outlined_card<State: 'static, V: View<State>>(child: V) -> CardView<State> {
card(CardVariant::Outlined, child)
}
impl<State: 'static> CardView<State> {
pub fn on_press<F: Fn(&mut State) + 'static>(mut self, on_press: F) -> Self {
self.on_press = Some(Rc::new(on_press));
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn haptic(mut self, haptic: HapticSignal) -> Self {
self.haptic = haptic;
self
}
}
pub struct CardWidget {
variant: CardVariant,
child: ChildPod,
interactive: bool,
enabled: bool,
haptic: HapticSignal,
captured: bool,
state: InteractionState,
elevation_anim: AnimationController,
elevation_target: bool,
on_press: Option<frust::authoring::ErasedCallback>,
}
impl<State: 'static> View<State> for CardView<State> {
type Element = CardWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> CardWidget {
CardWidget {
variant: self.variant,
child: frust::authoring::build_child(&self.child, ctx),
interactive: self.on_press.is_some(),
enabled: self.enabled,
haptic: self.haptic,
captured: false,
state: InteractionState::new(),
elevation_anim: AnimationController::new(Duration::ZERO),
elevation_target: false,
on_press: self.on_press.as_ref().map(frust::authoring::erase_callback),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut CardWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
if prev.variant != self.variant {
element.variant = self.variant;
flags |= ChangeFlags::PAINT;
}
let now_interactive = self.on_press.is_some();
if element.interactive != now_interactive {
element.interactive = now_interactive;
if !now_interactive {
element.captured = false;
element.state.set_pressed(false);
element.state.set_hovered(false);
}
flags |= ChangeFlags::PAINT;
}
if prev.enabled != self.enabled {
element.enabled = self.enabled;
if !self.enabled && element.interactive {
element.captured = false;
element.state.set_pressed(false);
element.state.set_hovered(false);
}
if element.interactive {
flags |= ChangeFlags::PAINT;
}
}
element.haptic = self.haptic;
element.on_press = self.on_press.as_ref().map(frust::authoring::erase_callback);
flags |= frust::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
flags
}
fn teardown(&self, element: &mut CardWidget, ctx: &mut BuildCtx<'_>) {
frust::authoring::teardown_child(&self.child, &mut element.child, ctx);
}
}
impl Widget for CardWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let inset = CARD_PADDING * 2.0;
let inner_max = Size::new(
(bc.max().width - inset).max(0.0),
(bc.max().height - inset).max(0.0),
);
let inner_min = Size::new(
(bc.min().width - inset).max(0.0),
(bc.min().height - inset).max(0.0),
);
let child_size = self
.child
.layout_child(ctx, &BoxConstraints::new(inner_min, inner_max));
self.child
.set_origin(Point::new(CARD_PADDING, CARD_PADDING));
bc.constrain(Size::new(
child_size.width + inset,
child_size.height + inset,
))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let disabled = self.interactive && !self.enabled;
let container = resolve_container(theme, self.variant, disabled);
let radius = resolve_radius(theme);
let hovered = self.interactive && self.enabled && ctx.is_hovered();
let (elevation_rest, elevation_hover) = resolve_elevation_endpoints(theme);
let shadow_color = resolve_shadow_color(theme);
let outline =
(self.variant == CardVariant::Outlined).then(|| resolve_outline(theme, disabled));
let content_color = resolve_content_color(theme);
self.state.set_hovered(hovered);
let o = ctx.origin();
let size = ctx.size();
if self.variant == CardVariant::Elevated {
if hovered != self.elevation_target {
let velocity = if hovered {
ELEVATION_RETARGET_VELOCITY
} else {
-ELEVATION_RETARGET_VELOCITY
};
self.elevation_anim
.fling(velocity, PressSpringId::DefaultEffects.resolve());
self.elevation_target = hovered;
}
if self.elevation_anim.advance(ctx.frame_time()) {
ctx.request_frame();
}
let t = self.elevation_anim.value_clamped();
let blur = Tween::new(elevation_rest.0, elevation_hover.0).lerp(t);
let y_offset = Tween::new(elevation_rest.1, elevation_hover.1).lerp(t);
scene.draw_shadow(
Point::new(o.x, o.y + y_offset),
size,
radius,
blur,
shadow_color,
);
}
scene.fill_rounded_rect(o, size, radius, container);
if let Some(outline) = outline {
let half = STROKE_WIDTH / 2.0;
let rr = RoundedRect::new(
half,
half,
size.width - half,
size.height - half,
(radius - half).max(0.0),
);
let path = rr.to_path(STROKE_TOLERANCE);
scene.stroke_path(o, &path, STROKE_WIDTH, &Brush::Solid(outline));
}
if self.interactive {
let opacity = self.state.resolve_opacity();
if opacity > 0.0 {
scene.fill_rounded_rect(o, size, radius, with_alpha(content_color, opacity));
}
}
self.child.paint_child(ctx, scene);
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if !self.interactive {
return frust::authoring::route_event_single(&mut self.child, ctx, event);
}
let InputEvent::Pointer(p) = event else {
return frust::authoring::route_event_single(&mut self.child, ctx, event);
};
if !self.enabled {
return EventResult::Ignored;
}
let on_press = self
.on_press
.as_mut()
.expect("on_press is set whenever interactive is true");
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
self.state.set_pressed(true);
self.captured = true;
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
if !self.captured {
let over = inside(p.position, ctx.size());
if over {
ctx.claim_hover();
}
if self.state.set_hovered(over) {
ctx.request_redraw();
}
return EventResult::Ignored;
}
let inside_now = inside(p.position, ctx.size());
if self.state.set_pressed(inside_now) {
ctx.request_redraw();
}
EventResult::Handled
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
if inside(p.position, ctx.size()) {
if self.haptic != HapticSignal::None {
MaterialHaptics::fire(self.haptic);
}
(on_press)(ctx);
}
self.state.set_pressed(false);
self.captured = false;
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.state.set_pressed(false);
self.captured = false;
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
if self.interactive {
ctx.push_container(
Role::Button,
|node| {
if self.enabled {
node.add_action(Action::Click);
} else {
node.set_disabled();
}
},
|ctx| {
self.child.semantics_child(ctx);
},
);
} else {
ctx.push_container(
Role::GenericContainer,
|_| {},
|ctx| {
self.child.semantics_child(ctx);
},
);
}
}
frust::authoring::visit_children!(child);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::FrameTime;
use frust_widgets::test_support::leaf_any;
use std::any::Any;
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(frust::authoring::PointerEvent {
phase,
position: Point::new(x, y),
button: frust::authoring::PointerButton::Primary,
})
}
fn ft_secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, f64, Color)>,
shadows: Vec<(Point, Size, f64, f64, Color)>,
strokes: Vec<(Point, f64, Color)>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
self.shadows.push((o, s, radius, std_dev, color));
}
fn stroke_path(
&mut self,
origin: Point,
_path: &kurbo::BezPath,
width: f64,
brush: &Brush,
) {
if let Brush::Solid(color) = brush {
self.strokes.push((origin, width, *color));
}
}
}
fn build<S: 'static>(view: &CardView<S>) -> CardWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn paint(w: &mut CardWidget, size: Size, theme: Option<&Theme>) -> Recorder {
let mut rec = Recorder::default();
let mut ctx = match theme {
Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
None => PaintCtx::new(Point::ZERO, size),
};
w.paint(&mut ctx, &mut rec);
rec
}
#[test]
fn layout_insets_child_by_card_padding() {
let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
assert_eq!(size, Size::new(40.0 + 32.0, 20.0 + 32.0));
assert_eq!(w.child.origin(), Point::new(CARD_PADDING, CARD_PADDING));
}
#[test]
fn elevated_unthemed_paints_container_and_shadow() {
let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
let mut w = build(&view);
let rec = paint(&mut w, Size::new(100.0, 60.0), None);
assert_eq!(rec.rrects[0].3, ELEVATED_CONTAINER);
assert_eq!(rec.rrects[0].2, RADIUS);
assert_eq!(rec.shadows.len(), 1);
assert_eq!(rec.shadows[0].3, FALLBACK_SHADOW_BLUR);
assert!(rec.strokes.is_empty(), "elevated card has no stroke");
}
#[test]
fn filled_unthemed_paints_container_with_no_shadow() {
let view: CardView<()> = filled_card(leaf_any(40.0, 20.0));
let mut w = build(&view);
let rec = paint(&mut w, Size::new(100.0, 60.0), None);
assert_eq!(rec.rrects[0].3, FILLED_CONTAINER);
assert!(rec.shadows.is_empty(), "filled card paints no shadow");
assert!(rec.strokes.is_empty(), "filled card has no stroke");
}
#[test]
fn outlined_unthemed_paints_container_and_stroke_with_no_shadow() {
let view: CardView<()> = outlined_card(leaf_any(40.0, 20.0));
let mut w = build(&view);
let rec = paint(&mut w, Size::new(100.0, 60.0), None);
assert_eq!(rec.rrects[0].3, OUTLINED_CONTAINER);
assert!(rec.shadows.is_empty(), "outlined card paints no shadow");
assert_eq!(rec.strokes.len(), 1);
assert_eq!(rec.strokes[0].1, STROKE_WIDTH);
assert_eq!(rec.strokes[0].2, OUTLINE_VARIANT);
}
#[test]
fn themed_elevated_paint_shadow_is_unchanged_on_m3_light() {
let theme = crate::baseline();
assert_eq!(theme.brightness, frust::Brightness::Light);
let level1 = theme.elevation.level1;
let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
let mut w = build(&view);
let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
assert_eq!(rec.shadows.len(), 1);
assert_eq!(rec.shadows[0].3, level1.shadow_light.blur_std_dev);
assert_eq!(
rec.shadows[0].4.components[3],
level1.shadow_light.color_alpha
);
assert_eq!(
rec.shadows[0].3,
level1.shadow(theme.brightness).blur_std_dev
);
}
#[test]
fn themed_paint_resolves_r11_tokens() {
let theme = crate::baseline();
let scheme = theme.scheme();
let elevated: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
let mut w = build(&elevated);
let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
assert_eq!(rec.rrects[0].3, scheme.surface_container_low);
assert_eq!(rec.rrects[0].2, theme.shape.medium);
let filled: CardView<()> = filled_card(leaf_any(40.0, 20.0));
let mut w = build(&filled);
let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
let outlined: CardView<()> = outlined_card(leaf_any(40.0, 20.0));
let mut w = build(&outlined);
let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
assert_eq!(rec.rrects[0].3, scheme.surface);
assert_eq!(rec.strokes[0].2, scheme.outline_variant);
}
#[test]
fn disabled_interactive_card_dims_container_uniformly_across_variants() {
let theme = crate::baseline();
let expected = with_alpha(theme.scheme().on_surface, DISABLED_CONTAINER_OPACITY);
for variant in [
CardVariant::Elevated,
CardVariant::Filled,
CardVariant::Outlined,
] {
let view: CardView<()> = card(variant, leaf_any(40.0, 20.0))
.on_press(|_: &mut ()| {})
.enabled(false);
let mut w = build(&view);
let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
assert_eq!(
rec.rrects[0].3, expected,
"{variant:?} container dims while disabled"
);
}
}
#[test]
fn disabled_interactive_outlined_card_dims_its_outline_too() {
let theme = crate::baseline();
let expected = with_alpha(theme.scheme().on_surface, DISABLED_CONTAINER_OPACITY);
let view: CardView<()> = outlined_card(leaf_any(40.0, 20.0))
.on_press(|_: &mut ()| {})
.enabled(false);
let mut w = build(&view);
let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
assert_eq!(rec.strokes[0].2, expected);
}
#[test]
fn disabled_interactive_elevated_card_still_rests_at_level1_unlifted() {
let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0))
.on_press(|_: &mut ()| {})
.enabled(false);
let mut w = build(&view);
let rec = paint(&mut w, Size::new(100.0, 60.0), None);
assert_eq!(rec.shadows.len(), 1);
assert_eq!(
rec.shadows[0].3, FALLBACK_SHADOW_BLUR,
"never lifts: a disabled card never hovers"
);
assert!(
rec.rrects.get(1).is_none(),
"no state-layer overlay while disabled"
);
}
#[derive(Default)]
struct Counter {
presses: u32,
}
fn dispatch<S: 'static>(w: &mut CardWidget, state: &mut S, event: &InputEvent) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(200.0, 200.0));
w.event(&mut ctx, event)
}
#[test]
fn non_interactive_card_forwards_events_to_its_child() {
let view: CardView<Counter> =
elevated_card(frust::button::<Counter, _>("go", |s: &mut Counter| {
s.presses += 1
}));
let mut w = build(&view);
let mut tcx = frust::authoring::text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
let origin = w.child.origin();
let mut state = Counter::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, origin.x + 2.0, origin.y + 2.0),
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, origin.x + 2.0, origin.y + 2.0),
);
assert_eq!(state.presses, 1, "the nested button fires through the card");
}
fn content_stub<S: 'static>() -> AnyView<S> {
any::<S, _>(frust::text("content"))
}
#[test]
fn interactive_card_fires_on_up_inside_without_forwarding_to_child() {
let view: CardView<Counter> =
filled_card(content_stub::<Counter>()).on_press(|s: &mut Counter| s.presses += 1);
let mut w = build(&view);
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
assert!(w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.presses, 1);
}
#[test]
fn interactive_card_up_outside_does_not_fire() {
let view: CardView<Counter> =
filled_card(content_stub::<Counter>()).on_press(|s: &mut Counter| s.presses += 1);
let mut w = build(&view);
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 500.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 500.0));
assert_eq!(state.presses, 0);
}
#[test]
fn interactive_card_cancel_clears_armed_state() {
let view: CardView<Counter> =
filled_card(content_stub::<Counter>()).on_press(|s: &mut Counter| s.presses += 1);
let mut w = build(&view);
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
assert!(w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
assert!(!w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.presses, 0);
}
#[test]
fn disabled_interactive_card_ignores_every_pointer_phase() {
let view: CardView<Counter> = filled_card(content_stub::<Counter>())
.on_press(|s: &mut Counter| s.presses += 1)
.enabled(false);
let mut w = build(&view);
let mut state = Counter::default();
assert_eq!(
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0)),
EventResult::Ignored
);
assert!(!w.captured, "a disabled card never captures");
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.presses, 0, "and never fires");
}
struct FocusConsumer;
impl FocusConsumer {
fn new() -> Self {
FocusConsumer
}
}
struct FocusConsumerWidget;
impl View<Counter> for FocusConsumer {
type Element = FocusConsumerWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> FocusConsumerWidget {
FocusConsumerWidget
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut FocusConsumerWidget,
_ctx: &mut BuildCtx<'_>,
) -> frust::authoring::ChangeFlags {
frust::authoring::ChangeFlags::NONE
}
}
impl Widget for FocusConsumerWidget {
fn layout(&mut self, _ctx: &mut frust::authoring::LayoutCtx, bc: &BoxConstraints) -> Size {
bc.max()
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, _ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if let InputEvent::Key(_) = event {
return EventResult::Handled;
}
EventResult::Ignored
}
}
#[test]
fn interactive_card_forwards_key_events_to_focused_child() {
let view: CardView<Counter> =
filled_card(FocusConsumer::new()).on_press(|s: &mut Counter| s.presses += 1);
let mut w = build(&view);
let mut state = Counter::default();
w.child.set_focused(true);
let key_event = InputEvent::Key(frust::authoring::KeyEvent {
key: frust::authoring::Key::Named(frust::authoring::NamedKey::Backspace),
modifiers: frust::authoring::Modifiers::default(),
repeat: false,
});
let result = dispatch(&mut w, &mut state, &key_event);
assert_eq!(
result,
EventResult::Handled,
"key event must be forwarded to child"
);
assert_eq!(
state.presses, 0,
"card press callback does not fire on key event"
);
}
#[test]
fn semantics_interactive_card_is_a_button_container_forwarding_child() {
fn logic(_s: &mut ()) -> CardView<()> {
filled_card(frust::text("body")).on_press(|_s: &mut ()| {})
}
let mut root: frust_core::RenderRoot<(), CardView<()>> = frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = frust::authoring::text::TextContext::new();
root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Button)
.expect("interactive card contributes a Role::Button container node");
assert!(
!node.children().is_empty(),
"the child is a semantics child"
);
assert!(!node.is_disabled());
}
#[test]
fn semantics_disabled_interactive_card_reports_disabled_and_no_click_action() {
fn logic(_s: &mut ()) -> CardView<()> {
filled_card(frust::text("body"))
.on_press(|_s: &mut ()| {})
.enabled(false)
}
let mut root: frust_core::RenderRoot<(), CardView<()>> = frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = frust::authoring::text::TextContext::new();
root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Button)
.expect("a disabled interactive card still contributes a Role::Button node");
assert!(node.is_disabled());
}
#[test]
fn semantics_non_interactive_card_is_a_generic_container() {
fn logic(_s: &mut ()) -> CardView<()> {
filled_card(frust::text("body"))
}
let mut root: frust_core::RenderRoot<(), CardView<()>> = frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = frust::authoring::text::TextContext::new();
root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::GenericContainer)
.expect("non-interactive card contributes a Role::GenericContainer node");
assert!(
!node.children().is_empty(),
"the child is a semantics child"
);
}
const HARNESS_WINDOW: Size = Size::new(300.0, 200.0);
struct Harness {
root: frust_core::RenderRoot<u32, CardView<u32>>,
state: u32,
tcx: frust::authoring::text::TextContext,
variant: CardVariant,
enabled: bool,
}
impl Harness {
fn new(variant: CardVariant, enabled: bool) -> Self {
let mut h = Self {
root: frust_core::RenderRoot::new(),
state: 0,
tcx: frust::authoring::text::TextContext::new(),
variant,
enabled,
};
h.sync();
h
}
fn sync(&mut self) {
let (variant, enabled) = (self.variant, self.enabled);
let mut app = move |_: &mut u32| {
card::<u32, _>(variant, content_stub::<u32>())
.on_press(|s: &mut u32| *s += 1)
.enabled(enabled)
};
self.root.rebuild(&mut app, &mut self.state);
self.root
.layout_with_text(HARNESS_WINDOW, &mut self.tcx as &mut dyn Any);
}
fn dispatch(&mut self, phase: PointerPhase, x: f64, y: f64) {
self.root.event(
&mut self.state,
&InputEvent::Pointer(frust::authoring::PointerEvent {
phase,
position: Point::new(x, y),
button: frust::authoring::PointerButton::Primary,
}),
);
}
fn paint_at(&mut self, ft: FrameTime) -> Recorder {
let mut rec = Recorder::default();
self.root.paint(&mut rec, ft);
rec
}
fn overlay_alpha(&mut self, ft: FrameTime) -> Option<f32> {
let rec = self.paint_at(ft);
rec.rrects
.get(1)
.map(|(_, _, _, color)| color.components[3])
}
}
#[test]
fn hover_lifts_the_elevated_card_shadow_from_level1_to_level2_and_back() {
let mut h = Harness::new(CardVariant::Elevated, true);
let rest = h.paint_at(FrameTime::ZERO);
assert_eq!(rest.shadows[0].3, FALLBACK_SHADOW_BLUR, "rests at level1");
assert_eq!(rest.shadows[0].0, Point::new(0.0, FALLBACK_SHADOW_Y_OFFSET));
h.dispatch(PointerPhase::Move, 10.0, 10.0);
h.paint_at(ft_secs(0.0));
let settled = h.paint_at(ft_secs(1.0));
assert_eq!(
settled.shadows[0].3, FALLBACK_HOVER_SHADOW_BLUR,
"settles at level2's blur once the hover-lift spring settles"
);
assert_eq!(
settled.shadows[0].0,
Point::new(0.0, FALLBACK_HOVER_SHADOW_Y_OFFSET)
);
h.dispatch(PointerPhase::Move, 290.0, 190.0);
h.paint_at(ft_secs(1.0));
let dropped = h.paint_at(ft_secs(2.0));
assert_eq!(
dropped.shadows[0].3, FALLBACK_SHADOW_BLUR,
"drops back to level1 once unhovered"
);
}
#[test]
fn hover_never_lifts_a_disabled_interactive_card() {
let mut h = Harness::new(CardVariant::Elevated, false);
h.dispatch(PointerPhase::Move, 10.0, 10.0);
let rec = h.paint_at(ft_secs(1.0));
assert_eq!(
rec.shadows[0].3, FALLBACK_SHADOW_BLUR,
"a disabled card never reacts to hover (the radio-hover-regression fix)"
);
h.dispatch(PointerPhase::Down, 10.0, 10.0);
h.dispatch(PointerPhase::Up, 10.0, 10.0);
assert_eq!(h.state, 0, "and never fires either");
}
#[test]
fn hovering_paints_the_hover_state_layer_and_pressing_outranks_it() {
let mut h = Harness::new(CardVariant::Filled, true);
assert_eq!(h.overlay_alpha(FrameTime::ZERO), None, "no overlay at rest");
h.dispatch(PointerPhase::Move, 10.0, 10.0);
assert_eq!(
h.overlay_alpha(ft_secs(0.0)),
Some(crate::interaction::HOVER_OPACITY),
"the hovered card tints at the M3E hover opacity"
);
h.dispatch(PointerPhase::Down, 10.0, 10.0);
assert_eq!(
h.overlay_alpha(ft_secs(0.0)),
Some(frust::authoring::PRESSED_OPACITY),
"pressed outranks hover in the precedence order"
);
h.dispatch(PointerPhase::Up, 10.0, 10.0);
assert_eq!(h.state, 1, "and the release fired the callback");
}
#[test]
fn disabled_card_never_tints_and_never_fires() {
let mut h = Harness::new(CardVariant::Filled, false);
h.dispatch(PointerPhase::Move, 10.0, 10.0);
assert_eq!(
h.overlay_alpha(ft_secs(0.0)),
None,
"a disabled card claims no hover"
);
h.dispatch(PointerPhase::Down, 10.0, 10.0);
h.dispatch(PointerPhase::Up, 10.0, 10.0);
assert_eq!(h.state, 0);
}
}