1use tessera_ui::{
8 Callback, Color, Dp, Modifier, RenderSlot, State, accesskit::Role, receive_frame_nanos,
9 remember, tessera, use_context,
10};
11
12use crate::{
13 animation::Animatable,
14 modifier::InteractionState,
15 shape_def::Shape,
16 surface::{SurfaceStyle, surface},
17 theme::{ContentColor, MaterialAlpha, MaterialTheme, content_color_for},
18};
19
20const DEFAULT_SPATIAL_DAMPING_RATIO: f32 = 0.9;
21const DEFAULT_SPATIAL_STIFFNESS: f32 = 700.0;
22
23#[cfg(test)]
24mod tests;
25
26fn composite_over(base: Color, overlay: Color) -> Color {
27 let overlay_a = overlay.a.clamp(0.0, 1.0);
28 let base_a = base.a.clamp(0.0, 1.0);
29 let out_a = overlay_a + base_a * (1.0 - overlay_a);
30 if out_a <= 0.0 {
31 return Color::TRANSPARENT;
32 }
33
34 let r = (overlay.r * overlay_a + base.r * base_a * (1.0 - overlay_a)) / out_a;
35 let g = (overlay.g * overlay_a + base.g * base_a * (1.0 - overlay_a)) / out_a;
36 let b = (overlay.b * overlay_a + base.b * base_a * (1.0 - overlay_a)) / out_a;
37 Color::new(r, g, b, out_a)
38}
39
40#[derive(Clone, PartialEq, Debug)]
41struct CardElevationSpring {
42 last_frame_nanos: Option<u64>,
43 spring: Animatable,
44}
45
46impl CardElevationSpring {
47 fn new(initial: Dp) -> Self {
48 Self {
49 last_frame_nanos: None,
50 spring: Animatable::spring(
51 initial.0 as f32,
52 DEFAULT_SPATIAL_STIFFNESS.sqrt(),
53 DEFAULT_SPATIAL_DAMPING_RATIO,
54 ),
55 }
56 }
57
58 fn set_target(&mut self, target: Dp) {
59 if self.spring.target() != target.0 as f32 {
60 self.last_frame_nanos = None;
61 }
62 self.spring.set_target(target.0 as f32);
63 }
64
65 fn snap_to(&mut self, target: Dp) {
66 *self = Self::new(target);
67 }
68
69 fn tick(&mut self, frame_nanos: u64) {
70 let dt = if let Some(last_frame_nanos) = self.last_frame_nanos {
71 frame_nanos.saturating_sub(last_frame_nanos) as f32 / 1_000_000_000.0
72 } else {
73 1.0 / 60.0
74 };
75 self.last_frame_nanos = Some(frame_nanos);
76 self.spring.tick(dt);
77 }
78
79 fn is_animating(&self) -> bool {
80 !self.spring.is_idle()
81 }
82
83 fn value_dp(&self) -> Dp {
84 Dp(self.spring.value() as f64)
85 }
86}
87
88#[derive(Clone, PartialEq, Copy, Debug, Default)]
90pub enum CardVariant {
91 #[default]
93 Filled,
94 Elevated,
96 Outlined,
98}
99
100#[derive(Clone, PartialEq, Copy, Debug)]
103pub struct CardColors {
104 pub container_color: Color,
106 pub content_color: Color,
108 pub disabled_container_color: Color,
110 pub disabled_content_color: Color,
112}
113
114impl CardColors {
115 fn container_color(self, enabled: bool) -> Color {
116 if enabled {
117 self.container_color
118 } else {
119 self.disabled_container_color
120 }
121 }
122
123 fn content_color(self, enabled: bool) -> Color {
124 if enabled {
125 self.content_color
126 } else {
127 self.disabled_content_color
128 }
129 }
130}
131
132#[derive(Clone, PartialEq, Copy, Debug)]
134pub struct CardBorder {
135 pub width: Dp,
137 pub color: Color,
139}
140
141#[derive(Clone, PartialEq, Copy, Debug)]
143pub struct CardElevation {
144 default_elevation: Dp,
145 pressed_elevation: Dp,
146 focused_elevation: Dp,
147 hovered_elevation: Dp,
148 dragged_elevation: Dp,
149 disabled_elevation: Dp,
150}
151
152impl CardElevation {
153 fn default_elevation(self) -> Dp {
154 self.default_elevation
155 }
156
157 fn target(self, enabled: bool, interaction_state: Option<State<InteractionState>>) -> Dp {
158 if !enabled {
159 return self.disabled_elevation;
160 }
161
162 let Some(state) = interaction_state else {
163 return self.default_elevation;
164 };
165
166 state.with(|s| {
167 if s.is_dragged() {
168 self.dragged_elevation
169 } else if s.is_pressed() {
170 self.pressed_elevation
171 } else if s.is_focused() {
172 self.focused_elevation
173 } else if s.is_hovered() {
174 self.hovered_elevation
175 } else {
176 self.default_elevation
177 }
178 })
179 }
180}
181
182pub struct CardDefaults;
184
185impl CardDefaults {
186 pub const DISABLED_CONTAINER_OPACITY: f32 = 0.38;
188 pub const DISABLED_CONTENT_ALPHA: f32 = MaterialAlpha::DISABLED_CONTENT;
190 pub const DISABLED_OUTLINE_ALPHA: f32 = 0.12;
192
193 pub fn shape(theme: &MaterialTheme) -> Shape {
195 theme.shapes.medium
196 }
197
198 pub fn elevated_shape(theme: &MaterialTheme) -> Shape {
200 theme.shapes.medium
201 }
202
203 pub fn outlined_shape(theme: &MaterialTheme) -> Shape {
205 theme.shapes.medium
206 }
207
208 pub fn card_elevation() -> CardElevation {
210 CardElevation {
211 default_elevation: Dp(0.0),
212 pressed_elevation: Dp(0.0),
213 focused_elevation: Dp(0.0),
214 hovered_elevation: Dp(1.0),
215 dragged_elevation: Dp(3.0),
216 disabled_elevation: Dp(0.0),
217 }
218 }
219
220 pub fn elevated_card_elevation() -> CardElevation {
222 CardElevation {
223 default_elevation: Dp(1.0),
224 pressed_elevation: Dp(1.0),
225 focused_elevation: Dp(1.0),
226 hovered_elevation: Dp(2.0),
227 dragged_elevation: Dp(4.0),
228 disabled_elevation: Dp(1.0),
229 }
230 }
231
232 pub fn outlined_card_elevation() -> CardElevation {
234 Self::card_elevation()
235 }
236
237 pub fn card_colors(theme: &MaterialTheme, content_color: Color) -> CardColors {
239 let scheme = &theme.color_scheme;
240 let container = scheme.surface_container_highest;
241 let content = content_color_for(container, scheme).unwrap_or(content_color);
242 let disabled_overlay = scheme
243 .surface_variant
244 .with_alpha(Self::DISABLED_CONTAINER_OPACITY);
245 let disabled_container = composite_over(container, disabled_overlay);
246 CardColors {
247 container_color: container,
248 content_color: content,
249 disabled_container_color: disabled_container,
250 disabled_content_color: content.with_alpha(Self::DISABLED_CONTENT_ALPHA),
251 }
252 }
253
254 pub fn elevated_card_colors(theme: &MaterialTheme, content_color: Color) -> CardColors {
256 let scheme = &theme.color_scheme;
257 let container = scheme.surface_container_low;
258 let content = content_color_for(container, scheme).unwrap_or(content_color);
259 CardColors {
260 container_color: container,
261 content_color: content,
262 disabled_container_color: scheme.surface,
263 disabled_content_color: content.with_alpha(Self::DISABLED_CONTENT_ALPHA),
264 }
265 }
266
267 pub fn outlined_card_colors(theme: &MaterialTheme, content_color: Color) -> CardColors {
269 let scheme = &theme.color_scheme;
270 let container = scheme.surface;
271 let content = content_color_for(container, scheme).unwrap_or(content_color);
272 CardColors {
273 container_color: container,
274 content_color: content,
275 disabled_container_color: container,
276 disabled_content_color: content.with_alpha(Self::DISABLED_CONTENT_ALPHA),
277 }
278 }
279
280 pub fn outlined_card_border(theme: &MaterialTheme, enabled: bool) -> CardBorder {
282 let scheme = &theme.color_scheme;
283 let color = if enabled {
284 scheme.outline_variant
285 } else {
286 composite_over(
287 scheme.surface_container_low,
288 scheme.outline.with_alpha(Self::DISABLED_OUTLINE_ALPHA),
289 )
290 };
291 CardBorder {
292 width: Dp(1.0),
293 color,
294 }
295 }
296}
297
298#[tessera]
341pub fn card(
342 modifier: Option<Modifier>,
343 variant: Option<CardVariant>,
344 enabled: Option<bool>,
345 on_click: Option<Callback>,
346 interaction_state: Option<State<InteractionState>>,
347 shape: Option<Shape>,
348 colors: Option<CardColors>,
349 elevation: Option<CardElevation>,
350 border: Option<CardBorder>,
351 #[prop(into)] accessibility_label: Option<String>,
352 content: Option<RenderSlot>,
353) {
354 let modifier = modifier.unwrap_or_default();
355 let variant = variant.unwrap_or_default();
356 let enabled = enabled.unwrap_or(true);
357 let content = content.unwrap_or_else(RenderSlot::empty);
358
359 let theme = use_context::<MaterialTheme>()
360 .expect("MaterialTheme must be provided")
361 .get();
362 let content_color = use_context::<ContentColor>()
363 .map(|c| c.get().current)
364 .unwrap_or(ContentColor::default().current);
365
366 let shape = shape.unwrap_or_else(|| match variant {
367 CardVariant::Filled => CardDefaults::shape(&theme),
368 CardVariant::Elevated => CardDefaults::elevated_shape(&theme),
369 CardVariant::Outlined => CardDefaults::outlined_shape(&theme),
370 });
371
372 let colors = colors.unwrap_or_else(|| match variant {
373 CardVariant::Filled => CardDefaults::card_colors(&theme, content_color),
374 CardVariant::Elevated => CardDefaults::elevated_card_colors(&theme, content_color),
375 CardVariant::Outlined => CardDefaults::outlined_card_colors(&theme, content_color),
376 });
377
378 let elevation = elevation.unwrap_or_else(|| match variant {
379 CardVariant::Filled => CardDefaults::card_elevation(),
380 CardVariant::Elevated => CardDefaults::elevated_card_elevation(),
381 CardVariant::Outlined => CardDefaults::outlined_card_elevation(),
382 });
383
384 let clickable = on_click.is_some();
385 let interaction_state = if clickable {
386 Some(if let Some(state) = interaction_state {
387 state
388 } else {
389 remember(InteractionState::new)
390 })
391 } else {
392 None
393 };
394 if !enabled && let Some(state) = interaction_state {
395 if state.with(|state| *state != InteractionState::default()) {
396 state.set(InteractionState::default());
397 }
398 }
399 let border = match border {
400 Some(border) => Some(border),
401 None if matches!(variant, CardVariant::Outlined) => {
402 let mut border = CardDefaults::outlined_card_border(&theme, enabled);
403 if enabled
404 && interaction_state.is_some_and(|state| {
405 state.with(|state| {
406 state.is_focused() && !state.is_pressed() && !state.is_dragged()
407 })
408 })
409 {
410 border.color = theme.color_scheme.on_surface;
411 }
412 Some(border)
413 }
414 None => None,
415 };
416
417 let elevation_spring = remember(|| CardElevationSpring::new(elevation.default_elevation()));
418
419 let target = elevation.target(enabled, interaction_state);
420 let should_update_spring = elevation_spring.with(|spring| {
421 let mut next = spring.clone();
422 next.set_target(target);
423 if !enabled || !clickable {
424 next.snap_to(target);
425 }
426 next != *spring
427 });
428 if should_update_spring {
429 elevation_spring.with_mut(|spring| {
430 spring.set_target(target);
431 if !enabled || !clickable {
432 spring.snap_to(target);
433 }
434 });
435 }
436
437 let should_schedule_frame = elevation_spring.with(|spring| spring.is_animating());
438
439 if should_schedule_frame {
440 receive_frame_nanos(move |frame_nanos| {
441 let is_animating = elevation_spring.with_mut(|spring| {
442 spring.tick(frame_nanos);
443 spring.is_animating()
444 });
445 if is_animating {
446 tessera_ui::FrameNanosControl::Continue
447 } else {
448 tessera_ui::FrameNanosControl::Stop
449 }
450 });
451 }
452
453 let shadow_elevation = if clickable {
454 elevation_spring.with(|s| s.value_dp())
455 } else {
456 elevation.default_elevation()
457 };
458
459 let container_color = colors.container_color(enabled);
460 let content_color = colors.content_color(enabled);
461 let style = match border {
462 Some(border) => SurfaceStyle::FilledOutlined {
463 fill_color: container_color,
464 border_color: border.color,
465 border_width: border.width,
466 },
467 None => SurfaceStyle::Filled {
468 color: container_color,
469 },
470 };
471
472 surface()
473 .shape(shape)
474 .clip_content(true)
475 .modifier(modifier)
476 .content_color(content_color)
477 .elevation(shadow_elevation)
478 .enabled(enabled)
479 .style(style)
480 .interaction_state_optional(interaction_state)
481 .on_click_optional(on_click)
482 .accessibility_role_optional(clickable.then_some(Role::Button))
483 .accessibility_label_optional(accessibility_label)
484 .accessibility_focusable_optional(clickable.then_some(enabled))
485 .child_shared(content);
486}
487
488impl CardBuilder {
489 pub fn filled(&self) -> Self {
491 self.variant(CardVariant::Filled)
492 }
493
494 pub fn elevated(&self) -> Self {
496 self.variant(CardVariant::Elevated)
497 }
498
499 pub fn outlined(&self) -> Self {
501 self.variant(CardVariant::Outlined)
502 }
503}