Skip to main content

tessera_components/
surface.rs

1//! A flexible container component with styling and interaction options.
2//!
3//! ## Usage
4//!
5//! Use as a base for buttons, cards, or any styled and interactive region.
6use 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
46/// Material Design 3 defaults for [`surface`].
47pub struct SurfaceDefaults;
48
49impl SurfaceDefaults {
50    /// Default pressed ripple alpha used by surfaces.
51    pub const RIPPLE_ALPHA: f32 = MaterialAlpha::PRESSED;
52
53    /// Returns the standard ripple color for a surface.
54    pub fn ripple_color(scheme: &MaterialColorScheme) -> Color {
55        scheme.on_surface
56    }
57
58    /// Returns the ambient and directional shadow profiles for an elevation.
59    ///
60    /// Elevations interpolate between the standard 0–12dp levels. Use the
61    /// shadow modifier to draw these profiles with the ambient spread.
62    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/// Defines the visual style of the surface (fill, outline, or both).
71#[derive(Clone, PartialEq)]
72pub enum SurfaceStyle {
73    /// A solid color fill.
74    Filled {
75        /// Fill color used for the surface.
76        color: Color,
77    },
78    /// A solid color outline with a transparent fill.
79    Outlined {
80        /// Outline color for the surface border.
81        color: Color,
82        /// Width of the outline stroke.
83        width: Dp,
84    },
85    /// A solid color fill with a solid color outline.
86    FilledOutlined {
87        /// Fill color used for the surface.
88        fill_color: Color,
89        /// Outline color used to draw the border.
90        border_color: Color,
91        /// Width of the outline stroke.
92        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/// A deterministic circular ripple for a pressed visual presentation.
111///
112/// This sample affects drawing only. It does not dispatch input, activate the
113/// surface, or start a ripple animation.
114///
115/// ```rust
116/// use tessera_components::{
117///     button::button,
118///     modifier::{InteractionState, InteractionVisualState},
119///     surface::RipplePresentation,
120///     theme::material_theme,
121/// };
122/// use tessera_ui::{remember, tessera};
123///
124/// #[tessera]
125/// fn sample() {
126///     let live = remember(InteractionState::new);
127///     material_theme().child(move || {
128///         button()
129///             .interaction_state(live)
130///             .presentation_state(InteractionVisualState::Pressed)
131///             .presentation_ripple(RipplePresentation {
132///                 origin: [0.75, 1.0],
133///                 radius: 0.7,
134///                 opacity: 0.1,
135///             });
136///         assert_eq!(live.get(), InteractionState::new());
137///     });
138/// }
139/// # tessera_ui::testing::layout_test(sample).run();
140/// ```
141#[derive(Clone, Copy, Debug, Default, PartialEq)]
142pub struct RipplePresentation {
143    /// Origin relative to the surface: `[0, 0]` is the top-left corner and
144    /// `[1, 1]` is the bottom-right corner. Origins may lie outside the
145    /// surface. Non-finite coordinates use the center; coordinates are
146    /// bounded to `[-1024, 1024]`.
147    pub origin: [f32; 2],
148    /// Radius in multiples of the surface's shorter dimension, bounded to
149    /// `[0, 1024]`. Non-finite or negative radii produce no ripple.
150    pub radius: f32,
151    /// Ripple opacity in `[0, 1]`. Non-finite or negative opacity produces no
152    /// ripple.
153    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            // NOTE: No ripple variant for FilledOutlinedEllipse yet.
495            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 != &current_context) {
1009        context.set(current_context);
1010    }
1011    let current = policy;
1012    let live = remember(|| current.clone());
1013    if live.with(|value| value != &current) {
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/// # surface
1064///
1065/// Renders a styled container for content with optional interaction.
1066///
1067/// ## Usage
1068///
1069/// Wrap content to provide a visual background, shape, and optional click
1070/// handling with a ripple effect.
1071///
1072/// ## Parameters
1073///
1074/// - `modifier` — optional modifier chain applied to the surface subtree.
1075/// - `style` — optional visual style of the surface.
1076/// - `shape` — optional geometric outline of the surface.
1077/// - `clip_content` — clip the surface and its descendants to the shape while
1078///   preserving the outer shadow.
1079/// - `elevation` — optional shadow elevation.
1080/// - `tonal_elevation` — optional tonal elevation tint for theme surfaces.
1081/// - `content_color` — optional explicit content color override.
1082/// - `content_alignment` — optional child alignment within the surface bounds.
1083/// - `enabled` — optional interactive enabled flag.
1084/// - `on_long_click` — optional stationary long-press action; also available
1085///   through the context-menu key, Shift+F10, and accessibility context action.
1086///   A successful long press suppresses the release click.
1087/// - `on_click` — optional click callback.
1088/// - `ripple_color` — optional ripple color.
1089/// - `ripple_bounded` — optional bounded-ripple flag.
1090/// - `ripple_radius` — optional explicit ripple radius.
1091/// - `interaction_state` — optional shared interaction state.
1092/// - `presentation_state` — optional visual interaction; live input and
1093///   disabled styling take precedence.
1094/// - `presentation_ripple` — optional static circular ripple for an idle
1095///   pressed presentation.
1096/// - `show_focus_indicator` — show a 3dp focus outline with a 2dp gap; defaults
1097///   to false.
1098/// - `show_state_layer` — optional state-layer visibility flag.
1099/// - `show_ripple` — optional ripple visibility flag.
1100/// - `ripple_state` — optional shared ripple state.
1101/// - `block_input` — optional input-blocking flag.
1102/// - `accessibility_toggled` — optional selection state for toggle controls.
1103/// - `accessibility_role` — optional accessibility role.
1104/// - `accessibility_label` — optional accessibility label.
1105/// - `accessibility_description` — optional accessibility description.
1106/// - `accessibility_focusable` — optional accessibility focusable flag.
1107/// - `focus_requester` — optional externally managed focus requester.
1108/// - `focus_properties` — optional focus properties.
1109/// - `child` — optional child render slot.
1110///
1111/// ## Examples
1112///
1113/// ```
1114/// # use tessera_ui::tessera;
1115/// # #[tessera]
1116/// # fn component() {
1117/// use tessera_components::{
1118///     modifier::{ModifierExt, Padding},
1119///     surface::surface,
1120///     text::text,
1121/// };
1122/// use tessera_ui::{Dp, LayoutResult, Modifier};
1123/// # use tessera_components::theme::{MaterialTheme, material_theme};
1124///
1125/// # material_theme()
1126/// #     .theme(|| MaterialTheme::default())
1127/// #     .child(|| {
1128/// surface()
1129///     .modifier(Modifier::new().padding(Padding::all(Dp(16.0))))
1130///     .on_click(|| println!("Surface was clicked!"))
1131///     .child(|| {
1132///         text("Click me");
1133///     });
1134/// # });
1135/// # }
1136/// # tessera_ui::testing::layout_test(component).run();
1137/// ```
1138/// Renders a styled surface container.
1139#[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}