1use tessera_foundation::gesture::{LongPressRecognizer, LongPressSettings, TapRecognizer};
7use tessera_ui::{
8 AccessibilityActionHandler, AccessibilityNode, Callback, Color, ComputedData, Constraint, Dp,
9 FocusProperties, FocusRequester, LayoutResult, MeasurementError, Modifier, PointerInput,
10 PointerInputModifierNode, Px, PxPosition, PxSize, RenderSlot, SemanticsModifierNode, State,
11 accesskit::{Role, Toggled},
12 current_frame_nanos,
13 layout::{MeasureScope, layout},
14 modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
15 provide_context, receive_frame_nanos, remember, tessera, use_context,
16};
17
18use crate::{
19 RippleProps,
20 alignment::Alignment,
21 modifier::{
22 ClickableArgs, InteractionState, InteractionVisualState, ModifierExt, PointerEventContext,
23 ShadowArgs, apply_combined_clickable_modifier,
24 },
25 pipelines::{shape::command::ShapeCommand, simple_rect::command::SimpleRectCommand},
26 pos_misc::is_position_inside_bounds,
27 ripple_state::{RippleSpec, RippleState},
28 shape_def::{ResolvedShape, RoundedCorner, Shape},
29 theme::{ContentColor, MaterialAlpha, MaterialColorScheme, MaterialTheme, content_color_for},
30};
31
32#[cfg(all(test, not(target_family = "wasm")))]
33mod ripple_tests;
34
35#[derive(Clone, PartialEq, Copy, Debug)]
36struct AbsoluteTonalElevation {
37 current: Dp,
38}
39
40impl Default for AbsoluteTonalElevation {
41 fn default() -> Self {
42 Self { current: Dp(0.0) }
43 }
44}
45
46pub struct SurfaceDefaults;
48
49impl SurfaceDefaults {
50 pub const RIPPLE_ALPHA: f32 = MaterialAlpha::PRESSED;
52
53 pub fn ripple_color(scheme: &MaterialColorScheme) -> Color {
55 scheme.on_surface
56 }
57
58 pub fn synthesize_shadow_layers(
63 elevation: Dp,
64 scheme: &MaterialColorScheme,
65 ) -> crate::shadow::ShadowLayers {
66 crate::shadow::material_shadow(elevation, scheme).0
67 }
68}
69
70#[derive(Clone, PartialEq)]
72pub enum SurfaceStyle {
73 Filled {
75 color: Color,
77 },
78 Outlined {
80 color: Color,
82 width: Dp,
84 },
85 FilledOutlined {
87 fill_color: Color,
89 border_color: Color,
91 border_width: Dp,
93 },
94}
95
96impl Default for SurfaceStyle {
97 fn default() -> Self {
98 SurfaceStyle::Filled {
99 color: Color::new(0.0, 0.0, 0.0, 0.0),
100 }
101 }
102}
103
104impl From<Color> for SurfaceStyle {
105 fn from(color: Color) -> Self {
106 SurfaceStyle::Filled { color }
107 }
108}
109
110#[derive(Clone, Copy, Debug, Default, PartialEq)]
142pub struct RipplePresentation {
143 pub origin: [f32; 2],
148 pub radius: f32,
151 pub opacity: f32,
154}
155
156impl RipplePresentation {
157 fn sanitized(self) -> Self {
158 let coordinate = |value: f32| {
159 if value.is_finite() {
160 value.clamp(-1024.0, 1024.0)
161 } else {
162 0.5
163 }
164 };
165 Self {
166 origin: self.origin.map(coordinate),
167 radius: if self.radius.is_finite() {
168 self.radius.clamp(0.0, 1024.0)
169 } else {
170 0.0
171 },
172 opacity: if self.opacity.is_finite() {
173 self.opacity.clamp(0.0, 1.0)
174 } else {
175 0.0
176 },
177 }
178 }
179
180 fn props(self, color: Color, bounded: bool) -> RippleProps {
181 let presentation = self.sanitized();
182 RippleProps {
183 center: [presentation.origin[0] - 0.5, presentation.origin[1] - 0.5],
184 bounded,
185 radius: presentation.radius,
186 alpha: presentation.opacity,
187 color: color.with_alpha(1.0),
188 }
189 }
190}
191
192#[derive(Clone, Default, PartialEq)]
193struct SurfaceResolvedArgs {
194 modifier: Modifier,
195 style: SurfaceStyle,
196 shape: Shape,
197 clip_content: bool,
198 elevation: Option<Dp>,
199 tonal_elevation: Dp,
200 content_color: Option<Color>,
201 content_alignment: Alignment,
202 enabled: bool,
203 on_click: Option<Callback>,
204 on_long_click: Option<Callback>,
205 ripple_color: Color,
206 ripple_bounded: bool,
207 ripple_radius: Option<Dp>,
208 interaction_state: Option<State<InteractionState>>,
209 presentation_state: Option<InteractionVisualState>,
210 presentation_ripple: Option<RipplePresentation>,
211 show_focus_indicator: bool,
212 show_state_layer: bool,
213 show_ripple: bool,
214 ripple_state: Option<State<RippleState>>,
215 block_input: bool,
216 accessibility_role: Option<Role>,
217 accessibility_toggled: Option<Toggled>,
218 accessibility_label: Option<String>,
219 accessibility_description: Option<String>,
220 focus_requester: Option<FocusRequester>,
221 focus_properties: Option<FocusProperties>,
222 child: Option<RenderSlot>,
223}
224
225#[derive(Clone, Copy, PartialEq)]
226struct SurfaceInputOwner {
227 interaction: Option<State<InteractionState>>,
228 ripple: Option<State<RippleState>>,
229 tap: Option<State<TapRecognizer>>,
230 long_press: Option<State<LongPressRecognizer>>,
231}
232
233impl SurfaceInputOwner {
234 fn release(self) {
235 if let Some(state) = self.interaction
236 && state.is_alive()
237 {
238 state.with_mut(|state| {
239 state.release();
240 state.set_hovered(false);
241 state.set_focused(false);
242 state.set_dragged(false);
243 });
244 }
245 if let Some(state) = self.ripple
246 && state.is_alive()
247 {
248 state.with_mut(RippleState::release);
249 }
250 if let Some(state) = self.tap
251 && state.is_alive()
252 {
253 state.with_mut(TapRecognizer::cancel);
254 }
255 if let Some(state) = self.long_press
256 && state.is_alive()
257 {
258 state.with_mut(LongPressRecognizer::cancel);
259 }
260 }
261}
262
263struct SurfaceInputLease(SurfaceInputOwner);
264
265impl Drop for SurfaceInputLease {
266 fn drop(&mut self) {
267 self.0.release();
268 }
269}
270
271fn compute_content_offset(
272 alignment: Alignment,
273 container_w: Px,
274 container_h: Px,
275 content_w: Px,
276 content_h: Px,
277) -> (f32, f32) {
278 fn center_axis(container: Px, content: Px) -> f32 {
279 ((container.0 - content.0).max(0)) as f32 / 2.0
280 }
281
282 match alignment {
283 Alignment::TopStart => (0.0, 0.0),
284 Alignment::TopCenter => (center_axis(container_w, content_w), 0.0),
285 Alignment::TopEnd => ((container_w.0 - content_w.0).max(0) as f32, 0.0),
286 Alignment::CenterStart => (0.0, center_axis(container_h, content_h)),
287 Alignment::Center => (
288 center_axis(container_w, content_w),
289 center_axis(container_h, content_h),
290 ),
291 Alignment::CenterEnd => (
292 (container_w.0 - content_w.0).max(0) as f32,
293 center_axis(container_h, content_h),
294 ),
295 Alignment::BottomStart => (0.0, (container_h.0 - content_h.0).max(0) as f32),
296 Alignment::BottomCenter => (
297 center_axis(container_w, content_w),
298 (container_h.0 - content_h.0).max(0) as f32,
299 ),
300 Alignment::BottomEnd => (
301 (container_w.0 - content_w.0).max(0) as f32,
302 (container_h.0 - content_h.0).max(0) as f32,
303 ),
304 }
305}
306
307fn apply_tonal_elevation_to_style(
308 style: &SurfaceStyle,
309 scheme: &MaterialColorScheme,
310 absolute_tonal_elevation: Dp,
311) -> SurfaceStyle {
312 match style {
313 SurfaceStyle::Filled { color } => SurfaceStyle::Filled {
314 color: scheme.surface_color_at_elevation_for(*color, absolute_tonal_elevation),
315 },
316 SurfaceStyle::FilledOutlined {
317 fill_color,
318 border_color,
319 border_width,
320 } => SurfaceStyle::FilledOutlined {
321 fill_color: scheme
322 .surface_color_at_elevation_for(*fill_color, absolute_tonal_elevation),
323 border_color: *border_color,
324 border_width: *border_width,
325 },
326 SurfaceStyle::Outlined { .. } => style.clone(),
327 }
328}
329
330fn build_ripple_props(
331 args: &SurfaceResolvedArgs,
332 ripple_state: Option<State<RippleState>>,
333 frame_nanos: u64,
334) -> RippleProps {
335 if !args.show_ripple {
336 return RippleProps::default();
337 }
338 let Some(ripple_state) = ripple_state else {
339 return RippleProps::default();
340 };
341
342 if let Some(animation) =
343 ripple_state.with(|state| state.animation_snapshot_at_frame_nanos(frame_nanos))
344 {
345 return RippleProps {
346 center: [animation.center[0] - 0.5, animation.center[1] - 0.5],
347 bounded: args.ripple_bounded,
348 radius: animation.radius,
349 alpha: animation.alpha,
350 color: args.ripple_color.with_alpha(1.0),
351 };
352 }
353 RippleProps::default()
354}
355
356fn apply_state_layer_to_style(style: &SurfaceStyle, color: Color, alpha: f32) -> SurfaceStyle {
357 if alpha <= 0.0 {
358 return style.clone();
359 }
360
361 match style {
362 SurfaceStyle::Filled { color: fill_color } => SurfaceStyle::Filled {
363 color: fill_color.blend_over(color, alpha),
364 },
365 SurfaceStyle::Outlined {
366 color: border_color,
367 width,
368 } => SurfaceStyle::FilledOutlined {
369 fill_color: Color::TRANSPARENT.blend_over(color, alpha),
370 border_color: *border_color,
371 border_width: *width,
372 },
373 SurfaceStyle::FilledOutlined {
374 fill_color,
375 border_color,
376 border_width,
377 } => SurfaceStyle::FilledOutlined {
378 fill_color: fill_color.blend_over(color, alpha),
379 border_color: *border_color,
380 border_width: *border_width,
381 },
382 }
383}
384
385fn build_rounded_rectangle_command(
386 style: &SurfaceStyle,
387 ripple_props: RippleProps,
388 corner_radii: [f32; 4],
389 corner_g2: [f32; 4],
390 use_ripple: bool,
391) -> ShapeCommand {
392 match style {
393 SurfaceStyle::Filled { color } => {
394 if use_ripple {
395 ShapeCommand::RippleRect {
396 color: *color,
397 corner_radii,
398 corner_g2,
399 ripple: ripple_props,
400 }
401 } else {
402 ShapeCommand::Rect {
403 color: *color,
404 corner_radii,
405 corner_g2,
406 }
407 }
408 }
409 SurfaceStyle::Outlined { color, width } => {
410 if use_ripple {
411 ShapeCommand::RippleOutlinedRect {
412 color: *color,
413 corner_radii,
414 corner_g2,
415 border_width: width.to_pixels_f32(),
416 ripple: ripple_props,
417 }
418 } else {
419 ShapeCommand::OutlinedRect {
420 color: *color,
421 corner_radii,
422 corner_g2,
423 border_width: width.to_pixels_f32(),
424 }
425 }
426 }
427 SurfaceStyle::FilledOutlined {
428 fill_color,
429 border_color,
430 border_width,
431 } => {
432 if use_ripple {
433 ShapeCommand::RippleFilledOutlinedRect {
434 color: *fill_color,
435 border_color: *border_color,
436 corner_radii,
437 corner_g2,
438 border_width: border_width.to_pixels_f32(),
439 ripple: ripple_props,
440 }
441 } else {
442 ShapeCommand::FilledOutlinedRect {
443 color: *fill_color,
444 border_color: *border_color,
445 corner_radii,
446 corner_g2,
447 border_width: border_width.to_pixels_f32(),
448 }
449 }
450 }
451 }
452}
453
454fn build_ellipse_command(
455 style: &SurfaceStyle,
456 ripple_props: RippleProps,
457 use_ripple: bool,
458) -> ShapeCommand {
459 let corner_marker = [-1.0, -1.0, -1.0, -1.0];
460 match style {
461 SurfaceStyle::Filled { color } => {
462 if use_ripple {
463 ShapeCommand::RippleRect {
464 color: *color,
465 corner_radii: corner_marker,
466 corner_g2: [0.0; 4],
467 ripple: ripple_props,
468 }
469 } else {
470 ShapeCommand::Ellipse { color: *color }
471 }
472 }
473 SurfaceStyle::Outlined { color, width } => {
474 if use_ripple {
475 ShapeCommand::RippleOutlinedRect {
476 color: *color,
477 corner_radii: corner_marker,
478 corner_g2: [0.0; 4],
479 border_width: width.to_pixels_f32(),
480 ripple: ripple_props,
481 }
482 } else {
483 ShapeCommand::OutlinedEllipse {
484 color: *color,
485 border_width: width.to_pixels_f32(),
486 }
487 }
488 }
489 SurfaceStyle::FilledOutlined {
490 fill_color,
491 border_color,
492 border_width,
493 } => {
494 ShapeCommand::FilledOutlinedEllipse {
496 color: *fill_color,
497 border_color: *border_color,
498 border_width: border_width.to_pixels_f32(),
499 }
500 }
501 }
502}
503
504fn build_shape_command(
505 args: &SurfaceResolvedArgs,
506 style: &SurfaceStyle,
507 ripple_props: RippleProps,
508 size: PxSize,
509) -> ShapeCommand {
510 let use_ripple = args.show_ripple && ripple_props.alpha > 0.0;
511
512 match args.shape.resolve_for_size(size) {
513 ResolvedShape::Rounded {
514 corner_radii,
515 corner_g2,
516 } => build_rounded_rectangle_command(
517 style,
518 ripple_props,
519 corner_radii,
520 corner_g2,
521 use_ripple,
522 ),
523 ResolvedShape::Ellipse => build_ellipse_command(style, ripple_props, use_ripple),
524 }
525}
526
527fn try_build_simple_rect_command(
528 args: &SurfaceResolvedArgs,
529 style: &SurfaceStyle,
530 ripple_props: RippleProps,
531) -> Option<SimpleRectCommand> {
532 if args.show_ripple && ripple_props.alpha > 0.0 {
533 return None;
534 }
535
536 let color = match style {
537 SurfaceStyle::Filled { color } => *color,
538 _ => return None,
539 };
540
541 match args.shape {
542 Shape::RoundedRectangle {
543 top_left,
544 top_right,
545 bottom_right,
546 bottom_left,
547 ..
548 } => {
549 let corners = [top_left, top_right, bottom_right, bottom_left];
550 if corners
551 .iter()
552 .any(|corner| matches!(corner, RoundedCorner::Capsule))
553 {
554 return None;
555 }
556
557 let zero_eps = 0.0001;
558 if corners.iter().all(|corner| match corner {
559 RoundedCorner::Manual { radius, .. } => radius.to_pixels_f32().abs() <= zero_eps,
560 RoundedCorner::Capsule | RoundedCorner::Relative { .. } => false,
561 }) {
562 Some(SimpleRectCommand { color })
563 } else {
564 None
565 }
566 }
567 _ => None,
568 }
569}
570
571fn compute_surface_size(
572 parent_constraint: Constraint,
573 child_measurement: ComputedData,
574) -> (Px, Px) {
575 let width = parent_constraint.width.clamp(child_measurement.width);
576 let height = parent_constraint.height.clamp(child_measurement.height);
577
578 (width, height)
579}
580
581#[derive(Clone)]
582struct SurfaceLayout {
583 args: SurfaceResolvedArgs,
584 interaction_state: Option<State<InteractionState>>,
585 ripple_state: Option<State<RippleState>>,
586 scheme: MaterialColorScheme,
587 absolute_tonal_elevation: Dp,
588 ripple_frame_nanos: Option<u64>,
589 focus_visible: bool,
590}
591
592impl SurfaceLayout {
593 fn has_foreground(&self) -> bool {
594 !matches!(self.args.style, SurfaceStyle::Filled { .. })
595 || (self.args.show_state_layer
596 && (self.interaction_state.is_some() || self.args.presentation_state.is_some()))
597 || (self.args.show_focus_indicator
598 && (self.interaction_state.is_some() || self.args.presentation_state.is_some()))
599 || (self.args.show_ripple
600 && (self.args.on_click.is_some()
601 || self.ripple_state.is_some()
602 || (self.args.presentation_state == Some(InteractionVisualState::Pressed)
603 && self.args.presentation_ripple.is_some())))
604 }
605}
606
607impl PartialEq for SurfaceLayout {
608 fn eq(&self, other: &Self) -> bool {
609 self.args == other.args
610 && self.interaction_state == other.interaction_state
611 && self.ripple_state == other.ripple_state
612 && self.ripple_frame_nanos == other.ripple_frame_nanos
613 && self.focus_visible == other.focus_visible
614 && self.scheme == other.scheme
615 && self.absolute_tonal_elevation == other.absolute_tonal_elevation
616 }
617}
618
619impl tessera_ui::LayoutModifierNode for SurfaceLayout {
620 fn measure_eq(&self, other: &Self) -> bool {
621 self.has_foreground() == other.has_foreground()
622 }
623
624 fn placement_eq(&self, other: &Self) -> bool {
625 self.measure_eq(other) && self.args.content_alignment == other.args.content_alignment
626 }
627
628 fn place_children(
629 &self,
630 scope: &tessera_ui::layout::PlacementScope<'_>,
631 _inner: &tessera_ui::layout::PlacementResult,
632 ) -> Option<tessera_ui::layout::PlacementResult> {
633 let children = scope.children();
634 let (foreground, children) = if self.has_foreground() {
635 let (foreground, children) = children.split_last()?;
636 (Some(*foreground), children)
637 } else {
638 (None, children.as_slice())
639 };
640 let size = scope.size();
641 let child_width = children
642 .iter()
643 .map(|child| child.size().width)
644 .max()
645 .unwrap_or(Px::ZERO);
646 let child_height = children
647 .iter()
648 .map(|child| child.size().height)
649 .max()
650 .unwrap_or(Px::ZERO);
651 let (x, y) = compute_content_offset(
652 self.args.content_alignment,
653 size.width,
654 size.height,
655 child_width,
656 child_height,
657 );
658 let mut result = LayoutResult::new(size);
659 if let Some(foreground) = foreground {
660 result.place_child(foreground, PxPosition::ZERO);
661 }
662 for &child in children {
663 result.place_child(child, PxPosition::from_f32_xy(x, y));
664 }
665 Some(result.into_placement_result())
666 }
667
668 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
669 let mut result = LayoutResult::default();
670 let parent_constraint = *input.parent_constraint().as_ref();
671 let child_constraint = input.parent_constraint().without_min();
672 let children = input.children();
673 let (foreground, children) = if self.has_foreground() {
674 let (foreground, children) = children.split_last().expect("surface foreground");
675 (Some(*foreground), children)
676 } else {
677 (None, children.as_slice())
678 };
679
680 let child_measurement = if !children.is_empty() {
681 let mut max_width = Px::ZERO;
682 let mut max_height = Px::ZERO;
683 for &child in children {
684 let measurement = child.measure(input, &child_constraint)?;
685 max_width = max_width.max(measurement.width);
686 max_height = max_height.max(measurement.height);
687 }
688 ComputedData {
689 width: max_width,
690 height: max_height,
691 }
692 } else {
693 ComputedData {
694 width: Px(0),
695 height: Px(0),
696 }
697 };
698
699 let (width, height) = compute_surface_size(parent_constraint, child_measurement);
700 if let Some(foreground) = foreground {
701 foreground.measure(
702 input,
703 &Constraint::new(
704 tessera_ui::AxisConstraint::exact(width),
705 tessera_ui::AxisConstraint::exact(height),
706 ),
707 )?;
708 result.place_child(foreground, PxPosition::ZERO);
709 }
710
711 if !children.is_empty() {
712 let (extra_x, extra_y) = compute_content_offset(
713 self.args.content_alignment,
714 width,
715 height,
716 child_measurement.width,
717 child_measurement.height,
718 );
719 let origin = PxPosition::from_f32_xy(extra_x, extra_y);
720 for &child in children {
721 result.place_child(child, origin);
722 }
723 }
724
725 Ok(result.with_size(ComputedData { width, height }))
726 }
727}
728
729impl tessera_ui::DrawModifierNode for SurfaceLayout {
730 fn draw(
731 &self,
732 ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
733 content: &mut dyn tessera_ui::DrawModifierContent,
734 ) {
735 let input = &mut *ctx.render_input;
736 let effective_style = apply_tonal_elevation_to_style(
737 &self.args.style,
738 &self.scheme,
739 self.absolute_tonal_elevation,
740 );
741 let background = match effective_style {
742 SurfaceStyle::Filled { color } => Some(SurfaceStyle::Filled { color }),
743 SurfaceStyle::FilledOutlined { fill_color, .. } => {
744 Some(SurfaceStyle::Filled { color: fill_color })
745 }
746 SurfaceStyle::Outlined { .. } => None,
747 };
748 if let Some(background) = background {
749 record_surface_style(input, &self.args, &background, RippleProps::default());
750 }
751 content.draw(input);
752 }
753}
754
755fn record_surface_style(
756 input: &mut tessera_ui::layout::RenderInput<'_>,
757 args: &SurfaceResolvedArgs,
758 style: &SurfaceStyle,
759 ripple: RippleProps,
760) {
761 let mut metadata = input.metadata_mut();
762 let size = metadata.computed_data().expect("surface draw bounds");
763 if let Some(simple) = try_build_simple_rect_command(args, style, ripple) {
764 metadata.fragment_mut().push_draw_command(simple);
765 } else {
766 let drawable =
767 build_shape_command(args, style, ripple, PxSize::new(size.width, size.height));
768 metadata.fragment_mut().push_draw_command(drawable);
769 }
770}
771
772#[derive(Clone, PartialEq)]
773struct SurfaceForeground(SurfaceLayout);
774
775impl tessera_ui::DrawModifierNode for SurfaceForeground {
776 fn draw(
777 &self,
778 ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
779 content: &mut dyn tessera_ui::DrawModifierContent,
780 ) {
781 let policy = &self.0;
782 let live = policy
783 .interaction_state
784 .map(|state| state.get())
785 .unwrap_or_default();
786 let visual = live.with_presentation(policy.args.presentation_state, policy.args.enabled);
787 let alpha = if policy.args.enabled && policy.args.show_state_layer {
788 visual.state_layer_alpha()
789 } else {
790 0.0
791 };
792 let mut ripple = if policy.args.enabled {
793 policy
794 .ripple_frame_nanos
795 .map(|frame_nanos| {
796 build_ripple_props(&policy.args, policy.ripple_state, frame_nanos)
797 })
798 .unwrap_or_default()
799 } else {
800 RippleProps::default()
801 };
802 if policy.args.enabled
803 && policy.args.show_ripple
804 && live == InteractionState::new()
805 && policy.args.presentation_state == Some(InteractionVisualState::Pressed)
806 && let Some(presentation) = policy.args.presentation_ripple
807 {
808 ripple = presentation.props(policy.args.ripple_color, policy.args.ripple_bounded);
809 }
810 let style = match policy.args.style {
811 SurfaceStyle::Filled { .. } => SurfaceStyle::Filled {
812 color: Color::TRANSPARENT,
813 },
814 SurfaceStyle::Outlined { color, width } => SurfaceStyle::FilledOutlined {
815 fill_color: Color::TRANSPARENT,
816 border_color: color,
817 border_width: width,
818 },
819 SurfaceStyle::FilledOutlined {
820 border_color,
821 border_width,
822 ..
823 } => SurfaceStyle::FilledOutlined {
824 fill_color: Color::TRANSPARENT,
825 border_color,
826 border_width,
827 },
828 };
829 if alpha > 0.0 || ripple.alpha > 0.0 || !matches!(style, SurfaceStyle::Filled { .. }) {
830 let style =
831 apply_state_layer_to_style(&style, policy.args.ripple_color.with_alpha(1.0), alpha);
832 record_surface_style(ctx.render_input, &policy.args, &style, ripple);
833 }
834 if policy.args.show_focus_indicator
835 && visual.is_focused()
836 && (!live.is_focused() || policy.focus_visible)
837 {
838 record_focus_indicator(ctx.render_input, policy.args.shape, policy.scheme.secondary);
839 }
840 content.draw(ctx.render_input);
841 }
842}
843
844pub(crate) fn record_focus_indicator(
845 input: &mut tessera_ui::layout::RenderInput<'_>,
846 shape: Shape,
847 color: Color,
848) {
849 let mut metadata = input.metadata_mut();
850 let bounds = metadata
851 .computed_data()
852 .expect("surface focus indicator bounds");
853 let (command, size, position) =
854 focus_indicator_geometry(shape, PxSize::new(bounds.width, bounds.height), color);
855 let fragment = metadata.fragment_mut();
856 let index = fragment.push_draw_command(command);
857 let op = &mut fragment.ops_mut()[index as usize];
858 op.position_override = Some(position);
859 op.size_override = Some(size);
860}
861
862fn focus_indicator_geometry(
863 shape: Shape,
864 body_size: PxSize,
865 color: Color,
866) -> (ShapeCommand, PxSize, PxPosition) {
867 let expansion = Dp(10.0).to_px();
868 let outset = expansion.to_f32() / 2.0;
869 let size = PxSize::new(body_size.width + expansion, body_size.height + expansion);
870 let command = match shape.resolve_for_size(body_size) {
871 ResolvedShape::Rounded {
872 corner_radii,
873 corner_g2,
874 } => ShapeCommand::OutlinedRect {
875 color,
876 corner_radii: corner_radii.map(|radius| radius + outset),
877 corner_g2,
878 border_width: Dp(3.0).to_pixels_f32(),
879 },
880 ResolvedShape::Ellipse => ShapeCommand::OutlinedEllipse {
881 color,
882 border_width: Dp(3.0).to_pixels_f32(),
883 },
884 };
885 (command, size, PxPosition::from_f32_xy(-outset, -outset))
886}
887
888#[tessera]
889fn surface_foreground(paint: Option<State<SurfaceLayout>>) {
890 let paint = paint.expect("surface foreground paint").get();
891 if paint.has_foreground() {
892 layout().draw(SurfaceForeground(paint));
893 }
894}
895
896struct SurfaceBlockInputPointerModifierNode;
897
898impl PointerInputModifierNode for SurfaceBlockInputPointerModifierNode {
899 fn on_pointer_input(&self, mut input: PointerInput<'_>) {
900 let is_cursor_in_surface = input
901 .cursor_position_rel
902 .map(|pos| is_position_inside_bounds(input.computed_data, pos))
903 .unwrap_or(false);
904 if is_cursor_in_surface {
905 input.consume_pointer_changes();
906 }
907 }
908}
909
910fn apply_surface_block_input_modifier(base: Modifier, block_input: bool) -> Modifier {
911 if block_input {
912 base.push_pointer_input(SurfaceBlockInputPointerModifierNode)
913 } else {
914 base
915 }
916}
917
918#[tessera]
919fn surface_content(
920 resolved: Option<SurfaceResolvedArgs>,
921 interaction_state: Option<State<InteractionState>>,
922 ripple_state: Option<State<RippleState>>,
923) {
924 let surface = resolved.expect("surface_content requires resolved args");
925 let focus_visible = surface.show_focus_indicator
926 && surface
927 .focus_requester
928 .is_some_and(|requester| requester.is_focus_visible());
929 let scheme = use_context::<MaterialTheme>()
930 .expect("MaterialTheme must be provided")
931 .get()
932 .color_scheme;
933 let parent_absolute_elevation = use_context::<AbsoluteTonalElevation>()
934 .map(|e| e.get().current)
935 .unwrap_or_else(|| AbsoluteTonalElevation::default().current);
936 let absolute_tonal_elevation = Dp(parent_absolute_elevation.0 + surface.tonal_elevation.0);
937 let inherited_content_color = use_context::<ContentColor>()
938 .map(|c| c.get().current)
939 .unwrap_or_else(|| ContentColor::default().current);
940 let content_color = surface
941 .content_color
942 .unwrap_or_else(|| match &surface.style {
943 SurfaceStyle::Filled { color } => {
944 content_color_for(*color, &scheme).unwrap_or(inherited_content_color)
945 }
946 SurfaceStyle::FilledOutlined { fill_color, .. } => {
947 content_color_for(*fill_color, &scheme).unwrap_or(inherited_content_color)
948 }
949 SurfaceStyle::Outlined { .. } => inherited_content_color,
950 });
951 let clickable = surface.on_click.is_some() || surface.on_long_click.is_some();
952 let interactive = surface.enabled && clickable;
953
954 let mut ripple_frame_nanos = None;
955 if surface.show_ripple
956 && let Some(ripple_state) = ripple_state
957 {
958 let frame_nanos = current_frame_nanos();
959 let has_active_ripple = ripple_state.with(|state| {
960 state
961 .animation_snapshot_at_frame_nanos(frame_nanos)
962 .is_some()
963 });
964 if has_active_ripple {
965 ripple_frame_nanos = Some(frame_nanos);
966 receive_frame_nanos(move |frame_nanos| {
967 let has_active_ripple = ripple_state.with(|state| {
968 state
969 .animation_snapshot_at_frame_nanos(frame_nanos)
970 .is_some()
971 });
972 if has_active_ripple {
973 tessera_ui::FrameNanosControl::Continue
974 } else {
975 tessera_ui::FrameNanosControl::Stop
976 }
977 });
978 }
979 }
980
981 let layout_args = surface.clone();
982 let mut modifier =
983 apply_surface_block_input_modifier(Modifier::new(), !interactive && surface.block_input);
984 if let Some(elevation) = surface.elevation
985 && elevation.0 > 0.0
986 {
987 modifier = modifier.shadow(
988 &scheme,
989 &ShadowArgs {
990 elevation,
991 shape: surface.shape,
992 clip: false,
993 ..Default::default()
994 },
995 );
996 }
997 let policy = SurfaceLayout {
998 args: layout_args,
999 interaction_state,
1000 ripple_state,
1001 scheme,
1002 absolute_tonal_elevation,
1003 ripple_frame_nanos,
1004 focus_visible,
1005 };
1006 let current_context = (absolute_tonal_elevation, content_color, policy.args.child);
1007 let context = remember(|| current_context);
1008 if context.with(|value| value != ¤t_context) {
1009 context.set(current_context);
1010 }
1011 let current = policy;
1012 let live = remember(|| current.clone());
1013 if live.with(|value| value != ¤t) {
1014 live.set(current);
1015 }
1016 layout().modifier(modifier).child(move || {
1017 let policy = live.get();
1018 let modifier = if policy.args.clip_content {
1019 Modifier::new().clip_shape(policy.args.shape)
1020 } else {
1021 Modifier::new()
1022 };
1023 layout()
1024 .modifier(modifier)
1025 .policy(policy.clone())
1026 .draw(policy)
1027 .child(move || {
1028 let (absolute_tonal_elevation, content_color, child) = context.get();
1029 provide_context(
1030 || AbsoluteTonalElevation {
1031 current: absolute_tonal_elevation,
1032 },
1033 || {
1034 let elevation = use_context::<AbsoluteTonalElevation>().unwrap();
1035 if elevation.with(|value| value.current) != absolute_tonal_elevation {
1036 elevation.set(AbsoluteTonalElevation {
1037 current: absolute_tonal_elevation,
1038 });
1039 }
1040 provide_context(
1041 || ContentColor {
1042 current: content_color,
1043 },
1044 || {
1045 let color = use_context::<ContentColor>().unwrap();
1046 if color.with(|value| value.current) != content_color {
1047 color.set(ContentColor {
1048 current: content_color,
1049 });
1050 }
1051 if let Some(child) = child.as_ref() {
1052 child.render();
1053 }
1054 },
1055 );
1056 },
1057 );
1058 surface_foreground().paint(live);
1059 });
1060 });
1061}
1062
1063#[tessera]
1140pub fn surface(
1141 modifier: Option<Modifier>,
1142 style: Option<SurfaceStyle>,
1143 shape: Option<Shape>,
1144 clip_content: Option<bool>,
1145 elevation: Option<Dp>,
1146 tonal_elevation: Option<Dp>,
1147 content_color: Option<Color>,
1148 content_alignment: Option<Alignment>,
1149 enabled: Option<bool>,
1150 on_click: Option<Callback>,
1151 on_long_click: Option<Callback>,
1152 ripple_color: Option<Color>,
1153 ripple_bounded: Option<bool>,
1154 ripple_radius: Option<Dp>,
1155 interaction_state: Option<State<InteractionState>>,
1156 presentation_state: Option<InteractionVisualState>,
1157 presentation_ripple: Option<RipplePresentation>,
1158 show_focus_indicator: Option<bool>,
1159 show_state_layer: Option<bool>,
1160 show_ripple: Option<bool>,
1161 ripple_state: Option<State<RippleState>>,
1162 block_input: Option<bool>,
1163 accessibility_role: Option<Role>,
1164 accessibility_toggled: Option<Toggled>,
1165 #[prop(into)] accessibility_label: Option<String>,
1166 #[prop(into)] accessibility_description: Option<String>,
1167 accessibility_focusable: Option<bool>,
1168 focus_requester: Option<FocusRequester>,
1169 focus_properties: Option<FocusProperties>,
1170 child: Option<RenderSlot>,
1171) {
1172 let theme = use_context::<MaterialTheme>();
1173 let scheme = theme
1174 .expect("MaterialTheme must be provided")
1175 .get()
1176 .color_scheme;
1177 let ripple_color = if let Some(color) = ripple_color {
1178 color
1179 } else {
1180 use_context::<ContentColor>()
1181 .map(|color| color.get().current)
1182 .unwrap_or(scheme.on_surface)
1183 };
1184 let mut resolved = SurfaceResolvedArgs {
1185 modifier: modifier.unwrap_or_default(),
1186 style: style.unwrap_or(SurfaceStyle::Filled {
1187 color: scheme.surface,
1188 }),
1189 shape: shape.unwrap_or_default(),
1190 clip_content: clip_content.unwrap_or(false),
1191 elevation,
1192 tonal_elevation: tonal_elevation.unwrap_or(Dp(0.0)),
1193 content_color,
1194 content_alignment: content_alignment.unwrap_or_default(),
1195 enabled: enabled.unwrap_or(true),
1196 on_click,
1197 on_long_click,
1198 ripple_color,
1199 ripple_bounded: ripple_bounded.unwrap_or(true),
1200 ripple_radius,
1201 interaction_state,
1202 presentation_state,
1203 presentation_ripple: presentation_ripple.map(RipplePresentation::sanitized),
1204 show_focus_indicator: show_focus_indicator.unwrap_or(false),
1205 show_state_layer: show_state_layer.unwrap_or(true),
1206 show_ripple: show_ripple.unwrap_or(true),
1207 ripple_state,
1208 block_input: block_input.unwrap_or(false),
1209 accessibility_role,
1210 accessibility_toggled,
1211 accessibility_label,
1212 accessibility_description,
1213 focus_requester,
1214 focus_properties,
1215 child,
1216 };
1217 let mut modifier = resolved.modifier.clone();
1218 let clickable = resolved.on_click.is_some() || resolved.on_long_click.is_some();
1219 let interactive = resolved.enabled && clickable;
1220 let internal_focus_requester = remember(FocusRequester::new).get();
1221 let bound_focus_requester = resolved.focus_requester.unwrap_or(internal_focus_requester);
1222 resolved.focus_requester = Some(bound_focus_requester);
1223 let interaction_state = if let Some(state) = resolved.interaction_state {
1224 Some(state)
1225 } else if interactive {
1226 Some(remember(InteractionState::new))
1227 } else {
1228 None
1229 };
1230 let tap_recognizer = if interactive {
1231 Some(remember(TapRecognizer::default))
1232 } else {
1233 None
1234 };
1235 let ripple_state = if resolved.show_ripple {
1236 if let Some(state) = resolved.ripple_state {
1237 Some(state)
1238 } else if interactive {
1239 Some(remember(RippleState::new))
1240 } else {
1241 None
1242 }
1243 } else {
1244 None
1245 };
1246 let long_press_recognizer = if resolved.on_long_click.is_some() {
1247 let state = remember(|| {
1248 LongPressRecognizer::new(LongPressSettings {
1249 slop_px: Dp(8.0).to_pixels_f32(),
1250 ..Default::default()
1251 })
1252 });
1253 if !interactive && state.with(LongPressRecognizer::is_active) {
1254 state.with_mut(LongPressRecognizer::cancel);
1255 }
1256 if state.with(LongPressRecognizer::needs_frame) {
1257 let callback = resolved.on_long_click.expect("long click handler");
1258 receive_frame_nanos(move |frame| {
1259 if !state.is_alive() {
1260 return tessera_ui::FrameNanosControl::Stop;
1261 }
1262 let triggered = state.with_mut(|recognizer| recognizer.advance_frame_nanos(frame));
1263 if triggered {
1264 callback.call();
1265 }
1266 if state.with(LongPressRecognizer::needs_frame) {
1267 tessera_ui::FrameNanosControl::Continue
1268 } else {
1269 tessera_ui::FrameNanosControl::Stop
1270 }
1271 });
1272 }
1273 Some(state)
1274 } else {
1275 None
1276 };
1277 let owner = if interactive {
1278 let input = SurfaceInputOwner {
1279 interaction: interaction_state,
1280 ripple: ripple_state,
1281 tap: tap_recognizer,
1282 long_press: long_press_recognizer,
1283 };
1284 let owner = remember(|| SurfaceInputLease(input));
1285 let previous = owner.with(|lease| lease.0);
1286 if previous != input {
1287 previous.release();
1288 owner.with_mut(|lease| lease.0 = input);
1289 }
1290 Some(owner)
1291 } else {
1292 None
1293 };
1294 let has_semantics = resolved.accessibility_toggled.is_some()
1295 || resolved.accessibility_role.is_some()
1296 || resolved.accessibility_label.is_some()
1297 || resolved.accessibility_description.is_some()
1298 || accessibility_focusable.is_some();
1299
1300 if clickable {
1301 modifier = modifier.minimum_interactive_component_size();
1302 }
1303
1304 if interactive {
1305 let ripple_spec = RippleSpec {
1306 bounded: resolved.ripple_bounded,
1307 radius: resolved.ripple_radius,
1308 };
1309 let press_handler = ripple_state.map(|state| {
1310 let spec = ripple_spec;
1311 move |ctx: PointerEventContext| {
1312 state.with_mut(|s| {
1313 s.start_animation_with_spec(ctx.normalized_pos, ctx.size, spec);
1314 });
1315 }
1316 });
1317 let release_handler = ripple_state
1318 .map(|state| move |_ctx: PointerEventContext| state.with_mut(|s| s.release()));
1319 let clickable_args = ClickableArgs {
1320 on_click: resolved.on_click.unwrap_or_else(Callback::noop),
1321 enabled: resolved.enabled,
1322 block_input: resolved.block_input,
1323 on_press: press_handler.map(Into::into),
1324 on_release: release_handler.map(Into::into),
1325 role: resolved.accessibility_role,
1326 label: resolved.accessibility_label.clone(),
1327 description: resolved.accessibility_description.clone(),
1328 interaction_state,
1329 focus_requester: Some(bound_focus_requester),
1330 focus_properties: resolved.focus_properties,
1331 tap_recognizer,
1332 long_press_recognizer,
1333 };
1334
1335 modifier = apply_combined_clickable_modifier(
1336 modifier,
1337 clickable_args,
1338 resolved.on_long_click,
1339 resolved.on_click.is_some(),
1340 );
1341 if resolved.on_long_click.is_some() {
1342 let owner = owner.expect("interactive surface input owner");
1343 modifier = modifier.on_focus_changed(move |focus: tessera_ui::FocusState| {
1344 if !focus.has_focus() {
1345 if !owner.is_alive() {
1346 return;
1347 }
1348 let input = owner.with(|lease| lease.0);
1349 if input.long_press.is_some_and(|state| {
1350 state.is_alive() && state.with(LongPressRecognizer::is_active)
1351 }) {
1352 input.release();
1353 }
1354 }
1355 });
1356 }
1357 } else if resolved.block_input {
1358 modifier = modifier.block_touch_propagation();
1359 }
1360
1361 if !interactive && has_semantics {
1362 let semantics = SurfaceSemantics {
1363 role: resolved.accessibility_role,
1364 label: resolved.accessibility_label.clone(),
1365 description: resolved.accessibility_description.clone(),
1366 focusable: accessibility_focusable,
1367 disabled: !resolved.enabled,
1368 toggled: resolved.accessibility_toggled,
1369 };
1370 modifier = modifier.push_semantics(semantics);
1371 }
1372
1373 if interactive && resolved.accessibility_toggled.is_some() {
1374 modifier = modifier.push_semantics(SurfaceToggleSemantics(resolved.accessibility_toggled));
1375 }
1376
1377 layout().modifier(modifier).child(move || {
1378 surface_content()
1379 .resolved(resolved.clone())
1380 .interaction_state_optional(interaction_state)
1381 .ripple_state_optional(ripple_state);
1382 });
1383}
1384
1385struct SurfaceToggleSemantics(Option<Toggled>);
1386
1387struct SurfaceSemantics {
1388 role: Option<Role>,
1389 label: Option<String>,
1390 description: Option<String>,
1391 focusable: Option<bool>,
1392 disabled: bool,
1393 toggled: Option<Toggled>,
1394}
1395
1396impl SemanticsModifierNode for SurfaceSemantics {
1397 fn apply(
1398 &self,
1399 node: &mut AccessibilityNode,
1400 handler: &mut Option<AccessibilityActionHandler>,
1401 ) {
1402 if let Some(role) = self.role {
1403 node.role = Some(role);
1404 }
1405 if let Some(label) = &self.label {
1406 node.label = Some(label.clone());
1407 }
1408 if let Some(description) = &self.description {
1409 node.description = Some(description.clone());
1410 }
1411 if let Some(focusable) = self.focusable {
1412 node.focusable = focusable;
1413 }
1414 if let Some(toggled) = self.toggled {
1415 node.toggled = Some(toggled);
1416 }
1417 node.disabled = self.disabled;
1418 if self.disabled {
1419 node.actions.clear();
1420 *handler = None;
1421 }
1422 }
1423}
1424
1425impl SemanticsModifierNode for SurfaceToggleSemantics {
1426 fn apply(
1427 &self,
1428 accessibility: &mut AccessibilityNode,
1429 _action_handler: &mut Option<AccessibilityActionHandler>,
1430 ) {
1431 accessibility.toggled = self.0;
1432 }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437 use tessera_ui::{Color, Dp, Modifier, State, remember, tessera, use_context};
1438
1439 use crate::{
1440 boxed::boxed,
1441 button::button,
1442 column::column,
1443 modifier::{InteractionState, ModifierExt as _},
1444 row::row,
1445 spacer::spacer,
1446 theme::{ContentColor, MaterialColorScheme, material_theme},
1447 };
1448
1449 use super::{SurfaceLayout, SurfaceResolvedArgs, SurfaceStyle};
1450
1451 #[test]
1452 fn focus_indicator_preserves_measured_stroke_gap_and_corners() {
1453 crate::test_utils::run_isolated_test(
1454 "surface::tests::focus_indicator_preserves_measured_stroke_gap_and_corners",
1455 || {
1456 use tessera_ui::{Px, PxPosition, PxSize};
1457
1458 use crate::{pipelines::shape::command::ShapeCommand, shape_def::Shape};
1459
1460 for (shape, body, expected_size, expected_radii) in [
1461 (
1462 Shape::CAPSULE,
1463 PxSize::new(Px(56), Px(56)),
1464 PxSize::new(Px(66), Px(66)),
1465 [33.0; 4],
1466 ),
1467 (
1468 Shape::rounded_rectangle(Dp(12.0)),
1469 PxSize::new(Px(100), Px(40)),
1470 PxSize::new(Px(110), Px(50)),
1471 [17.0; 4],
1472 ),
1473 ] {
1474 let (command, size, position) =
1475 super::focus_indicator_geometry(shape, body, Color::RED);
1476 assert_eq!(size, expected_size);
1477 assert_eq!(position, PxPosition::from_f32_xy(-5.0, -5.0));
1478 let ShapeCommand::OutlinedRect {
1479 border_width,
1480 corner_radii,
1481 ..
1482 } = command
1483 else {
1484 panic!("rounded containers require a rounded focus outline");
1485 };
1486 assert_eq!(border_width, 3.0);
1487 assert_eq!(corner_radii, expected_radii);
1488 }
1489 },
1490 );
1491 }
1492
1493 #[derive(Clone, Default)]
1494 struct ColorFixture {
1495 enabled: Option<State<bool>>,
1496 content: Color,
1497 }
1498
1499 #[tessera]
1500 fn content_color_probe(probe: tessera_ui::testing::TestProbe<ColorFixture>) {
1501 let color = use_context::<ContentColor>().unwrap().get().current;
1502 probe.with_mut(|observed| observed.content = color);
1503 }
1504
1505 #[tessera]
1506 fn changing_button_fixture(probe: tessera_ui::testing::TestProbe<ColorFixture>) {
1507 let enabled = remember(|| false);
1508 probe.with_mut(|observed| observed.enabled = Some(enabled));
1509 material_theme().child(move || {
1510 let probe = probe.clone();
1511 button().text().enabled(enabled.get()).child(move || {
1512 content_color_probe(probe.clone());
1513 });
1514 });
1515 }
1516
1517 #[test]
1518 fn enabling_a_button_updates_inherited_content_color() {
1519 use tessera_ui::testing::{
1520 layout_test, pump_layout_test_session, start_layout_test_session,
1521 };
1522
1523 let probe = tessera_ui::testing::TestProbe::default();
1524 let observed = probe.clone();
1525 let mut session = start_layout_test_session(layout_test(move || {
1526 changing_button_fixture(observed.clone());
1527 }));
1528 pump_layout_test_session(&mut session, 0);
1529 let disabled = probe.get().content;
1530 probe.get().enabled.unwrap().set(true);
1531 for frame in 1..4 {
1532 pump_layout_test_session(&mut session, frame * 16_666_667);
1533 }
1534 assert_ne!(probe.get().content, disabled);
1535 assert_eq!(probe.get().content, MaterialColorScheme::default().primary);
1536 }
1537
1538 fn layout_with_style(style: SurfaceStyle) -> SurfaceLayout {
1539 SurfaceLayout {
1540 args: SurfaceResolvedArgs {
1541 style,
1542 ..SurfaceResolvedArgs::default()
1543 },
1544 interaction_state: None,
1545 ripple_state: None,
1546 ripple_frame_nanos: None,
1547 focus_visible: false,
1548 scheme: MaterialColorScheme::default(),
1549 absolute_tonal_elevation: Dp(0.0),
1550 }
1551 }
1552
1553 fn outlined_track() -> SurfaceStyle {
1554 SurfaceStyle::FilledOutlined {
1555 fill_color: Color::new(0.1, 0.2, 0.3, 1.0),
1556 border_color: Color::new(0.4, 0.5, 0.6, 1.0),
1557 border_width: Dp(1.0),
1558 }
1559 }
1560
1561 #[test]
1562 fn surface_layout_equality_tracks_style_changes() {
1563 let outlined = layout_with_style(outlined_track());
1564 let filled = layout_with_style(SurfaceStyle::Filled {
1565 color: Color::new(0.9, 0.1, 0.1, 1.0),
1566 });
1567
1568 assert!(outlined != filled);
1569 assert!(outlined == layout_with_style(outlined_track()));
1570 }
1571
1572 #[tessera]
1573 fn transparent_state_layer_pair(style: SurfaceStyle, reference: SurfaceStyle) {
1574 let interaction = remember(|| {
1575 let mut state = InteractionState::new();
1576 state.set_hovered(true);
1577 state
1578 });
1579 row().children(move || {
1580 super::surface()
1581 .modifier(Modifier::new().required_size(Dp(64.0), Dp(64.0)))
1582 .style(style.clone())
1583 .shape(crate::shape_def::Shape::RECTANGLE)
1584 .ripple_color(Color::BLUE)
1585 .interaction_state(interaction)
1586 .show_ripple(false);
1587 spacer().modifier(Modifier::new().width(Dp(8.0)));
1588 super::surface()
1589 .modifier(Modifier::new().required_size(Dp(64.0), Dp(64.0)))
1590 .style(reference.clone())
1591 .shape(crate::shape_def::Shape::RECTANGLE)
1592 .show_state_layer(false)
1593 .show_ripple(false);
1594 });
1595 }
1596
1597 #[tessera]
1598 fn transparent_state_layers_fixture() {
1599 material_theme().child(|| {
1600 boxed()
1601 .modifier(Modifier::new().fill_max_size().background(Color::WHITE))
1602 .children(|| {
1603 column().children(|| {
1604 for style in 0..3 {
1605 let fill = |color| match style {
1606 0 => SurfaceStyle::Filled { color },
1607 _ => SurfaceStyle::FilledOutlined {
1608 fill_color: color,
1609 border_color: Color::RED,
1610 border_width: Dp(1.0),
1611 },
1612 };
1613 let base = if style == 1 {
1614 SurfaceStyle::Outlined {
1615 color: Color::RED,
1616 width: Dp(1.0),
1617 }
1618 } else {
1619 fill(Color::TRANSPARENT)
1620 };
1621 transparent_state_layer_pair(base, fill(Color::BLUE.with_alpha(0.08)));
1622 spacer().modifier(Modifier::new().height(Dp(8.0)));
1623 }
1624 });
1625 });
1626 });
1627 }
1628
1629 fn check_transparent_state_layers(samples: u32) {
1630 let mut renderer = tessera_ui::HeadlessRenderer::new(
1631 Box::new(|| {
1632 transparent_state_layers_fixture();
1633 }),
1634 vec![Box::new(crate::TesseraComponents)],
1635 tessera_ui::HeadlessConfig {
1636 width: 200,
1637 height: 240,
1638 sample_count: samples,
1639 ..Default::default()
1640 },
1641 )
1642 .unwrap();
1643 for frames in [2, 8] {
1644 renderer.render_frames(frames);
1645 let pixels = renderer.read_pixels().unwrap();
1646 for y in [32usize, 104, 176] {
1647 let actual = (y * 200 + 32) * 4;
1648 let reference = (y * 200 + 104) * 4;
1649 assert_eq!(
1650 &pixels[actual..actual + 4],
1651 &pixels[reference..reference + 4],
1652 "transparent state layer, MSAA {samples}, y={y}, frames={frames}"
1653 );
1654 }
1655 }
1656 }
1657
1658 #[derive(Clone, PartialEq)]
1659 struct TransparentOutlineSample {
1660 fill_color: Color,
1661 ripple_opacity: f32,
1662 }
1663
1664 impl tessera_ui::DrawModifierNode for TransparentOutlineSample {
1665 fn draw(
1666 &self,
1667 ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
1668 content: &mut dyn tessera_ui::DrawModifierContent,
1669 ) {
1670 ctx.render_input
1671 .metadata_mut()
1672 .fragment_mut()
1673 .push_draw_command(
1674 crate::pipelines::shape::command::ShapeCommand::RippleFilledOutlinedRect {
1675 color: self.fill_color,
1676 border_color: Color::new(0.1, 0.4, 0.2, 1.0),
1677 corner_radii: [12.0; 4],
1678 corner_g2: [2.0; 4],
1679 border_width: 3.0,
1680 ripple: crate::RippleProps {
1681 center: [0.0, 0.0],
1682 radius: 0.6,
1683 alpha: self.ripple_opacity,
1684 bounded: true,
1685 color: Color::BLACK,
1686 },
1687 },
1688 );
1689 content.draw(ctx.render_input);
1690 }
1691 }
1692
1693 #[tessera]
1694 fn transparent_outline_rgb_sample(fill_color: Color, ripple_opacity: f32) {
1695 tessera_ui::layout::layout()
1696 .modifier(Modifier::new().required_size(Dp(64.0), Dp(64.0)))
1697 .draw(TransparentOutlineSample {
1698 fill_color,
1699 ripple_opacity,
1700 });
1701 }
1702
1703 #[tessera]
1704 fn transparent_outline_rgb_fixture() {
1705 material_theme().child(|| {
1706 boxed()
1707 .modifier(
1708 Modifier::new()
1709 .fill_max_size()
1710 .background(Color::WHITE)
1711 .padding_all(Dp(8.0)),
1712 )
1713 .children(|| {
1714 column().children(|| {
1715 for opacity in [0.0, 0.25] {
1716 row().children(move || {
1717 for color in [Color::RED, Color::BLUE] {
1718 transparent_outline_rgb_sample(color.with_alpha(0.0), opacity);
1719 spacer().modifier(Modifier::new().width(Dp(8.0)));
1720 }
1721 });
1722 spacer().modifier(Modifier::new().height(Dp(8.0)));
1723 }
1724 });
1725 });
1726 });
1727 }
1728
1729 #[test]
1730 #[ignore = "requires a native GPU adapter"]
1731 fn transparent_outline_fill_rgb_does_not_affect_border_with_or_without_ripple() {
1732 crate::test_utils::run_isolated_test(
1733 "surface::tests::transparent_outline_fill_rgb_does_not_affect_border_with_or_without_ripple",
1734 || {
1735 let mut renderer = tessera_ui::HeadlessRenderer::new(
1736 Box::new(|| {
1737 transparent_outline_rgb_fixture();
1738 }),
1739 vec![Box::new(crate::TesseraComponents)],
1740 tessera_ui::HeadlessConfig {
1741 width: 160,
1742 height: 160,
1743 ..Default::default()
1744 },
1745 )
1746 .unwrap();
1747 renderer.render_frames(4);
1748 let pixels = renderer.read_pixels().unwrap();
1749 let crop = |left: usize, top: usize| {
1750 let mut result = Vec::new();
1751 for y in top..top + 64 {
1752 let offset = (y * 160 + left) * 4;
1753 result.extend_from_slice(&pixels[offset..offset + 64 * 4]);
1754 }
1755 result
1756 };
1757 let zero_ripple = crop(8, 8);
1758 assert!(
1759 zero_ripple
1760 .chunks_exact(4)
1761 .any(|pixel| pixel[..3] != [255; 3]),
1762 "the fixture renders a visible outline"
1763 );
1764 for (top, label) in [(8, "zero ripple"), (80, "static ripple")] {
1765 let left = crop(8, top);
1766 let right = crop(80, top);
1767 let difference = left
1768 .chunks_exact(4)
1769 .zip(right.chunks_exact(4))
1770 .position(|(a, b)| a != b);
1771 assert_eq!(
1772 difference, None,
1773 "transparent hidden RGB must not tint border coverage with {label}"
1774 );
1775 }
1776 assert_ne!(
1777 zero_ripple,
1778 crop(8, 80),
1779 "the second pair renders a visible native ripple"
1780 );
1781 },
1782 );
1783 }
1784
1785 #[test]
1786 #[ignore = "requires a native GPU adapter"]
1787 fn transparent_state_layers_match_direct_alpha_rendering_msaa1() {
1788 check_transparent_state_layers(1);
1789 }
1790
1791 #[test]
1792 #[ignore = "requires a native GPU adapter"]
1793 fn transparent_state_layers_match_direct_alpha_rendering_msaa4() {
1794 check_transparent_state_layers(4);
1795 }
1796}