tessera_ui_basic_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 std::sync::Arc;
7
8use derive_builder::Builder;
9use tessera_ui::{
10    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, GestureState,
11    InputHandlerInput, PressKeyEventType, Px, PxPosition, PxSize,
12    accesskit::{Action, Role},
13    tessera,
14    winit::window::CursorIcon,
15};
16
17use crate::{
18    RippleProps, ShadowProps,
19    material_color::global_material_scheme,
20    padding_utils::remove_padding_from_dimension,
21    pipelines::{shape::command::ShapeCommand, simple_rect::command::SimpleRectCommand},
22    pos_misc::is_position_in_component,
23    ripple_state::RippleState,
24    shape_def::{ResolvedShape, RoundedCorner, Shape},
25};
26
27/// Defines the visual style of the surface (fill, outline, or both).
28#[derive(Clone)]
29pub enum SurfaceStyle {
30    /// A solid color fill.
31    Filled {
32        /// Fill color used for the surface.
33        color: Color,
34    },
35    /// A solid color outline with a transparent fill.
36    Outlined {
37        /// Outline color for the surface border.
38        color: Color,
39        /// Width of the outline stroke.
40        width: Dp,
41    },
42    /// A solid color fill with a solid color outline.
43    FilledOutlined {
44        /// Fill color used for the surface.
45        fill_color: Color,
46        /// Outline color used to draw the border.
47        border_color: Color,
48        /// Width of the outline stroke.
49        border_width: Dp,
50    },
51}
52
53impl Default for SurfaceStyle {
54    fn default() -> Self {
55        let scheme = global_material_scheme();
56        SurfaceStyle::Filled {
57            color: scheme.surface,
58        }
59    }
60}
61
62impl From<Color> for SurfaceStyle {
63    fn from(color: Color) -> Self {
64        SurfaceStyle::Filled { color }
65    }
66}
67
68/// Arguments for the `surface` component.
69#[derive(Builder, Clone)]
70#[builder(pattern = "owned")]
71pub struct SurfaceArgs {
72    /// Defines the visual style of the surface (fill, outline, or both).
73    #[builder(default)]
74    pub style: SurfaceStyle,
75    /// Optional style to apply when the cursor is hovering over the surface.
76    /// This is only active when `on_click` is also provided.
77    #[builder(default)]
78    pub hover_style: Option<SurfaceStyle>,
79    /// Geometric outline of the surface (rounded rectangle / ellipse / capsule variants).
80    #[builder(default)]
81    pub shape: Shape,
82    /// Optional shadow/elevation style. When present it is passed through to the shape pipeline.
83    #[builder(default, setter(strip_option))]
84    pub shadow: Option<ShadowProps>,
85    /// Internal padding applied symmetrically (left/right & top/bottom). Child content is
86    /// positioned at (padding, padding). Also influences measured minimum size.
87    #[builder(default = "Dp(0.0)")]
88    pub padding: Dp,
89    /// Explicit width constraint (Fixed / Wrap / Fill). Defaults to `Wrap`.
90    #[builder(default = "DimensionValue::WRAP", setter(into))]
91    pub width: DimensionValue,
92    /// Explicit height constraint (Fixed / Wrap / Fill). Defaults to `Wrap`.
93    #[builder(default = "DimensionValue::WRAP", setter(into))]
94    pub height: DimensionValue,
95    /// Optional click handler. Presence of this value makes the surface interactive:
96    ///
97    /// * Cursor changes to pointer when hovered
98    /// * Press / release events are captured
99    /// * Ripple animation starts on press if a `RippleState` is provided
100    #[builder(default, setter(strip_option))]
101    pub on_click: Option<Arc<dyn Fn() + Send + Sync>>,
102    /// Color of the ripple effect (if interactive & ripple state provided).
103    #[builder(
104        default = "crate::material_color::global_material_scheme().on_surface.with_alpha(0.12)"
105    )]
106    pub ripple_color: Color,
107    /// If true, all input events inside the surface bounds are blocked (stop propagation),
108    /// after (optionally) handling its own click logic.
109    #[builder(default = "false")]
110    pub block_input: bool,
111    /// Optional explicit accessibility role. Defaults to `Role::Button` when interactive.
112    #[builder(default, setter(strip_option))]
113    pub accessibility_role: Option<Role>,
114    /// Optional label read by assistive technologies.
115    #[builder(default, setter(strip_option, into))]
116    pub accessibility_label: Option<String>,
117    /// Optional description read by assistive technologies.
118    #[builder(default, setter(strip_option, into))]
119    pub accessibility_description: Option<String>,
120    /// Whether this surface should be focusable even when not interactive.
121    #[builder(default)]
122    pub accessibility_focusable: bool,
123}
124
125impl Default for SurfaceArgs {
126    fn default() -> Self {
127        SurfaceArgsBuilder::default()
128            .build()
129            .expect("builder construction failed")
130    }
131}
132
133fn build_ripple_props(args: &SurfaceArgs, ripple_state: Option<&RippleState>) -> RippleProps {
134    if let Some(state) = ripple_state
135        && let Some((progress, click_pos)) = state.get_animation_progress()
136    {
137        let radius = progress;
138        let alpha = (1.0 - progress) * 0.3;
139        return RippleProps {
140            center: click_pos,
141            radius,
142            alpha,
143            color: args.ripple_color,
144        };
145    }
146    RippleProps::default()
147}
148
149fn build_rounded_rectangle_command(
150    args: &SurfaceArgs,
151    style: &SurfaceStyle,
152    ripple_props: RippleProps,
153    corner_radii: [f32; 4],
154    corner_g2: [f32; 4],
155    interactive: bool,
156) -> ShapeCommand {
157    match style {
158        SurfaceStyle::Filled { color } => {
159            if interactive {
160                ShapeCommand::RippleRect {
161                    color: *color,
162                    corner_radii,
163                    corner_g2,
164                    shadow: args.shadow,
165                    ripple: ripple_props,
166                }
167            } else {
168                ShapeCommand::Rect {
169                    color: *color,
170                    corner_radii,
171                    corner_g2,
172                    shadow: args.shadow,
173                }
174            }
175        }
176        SurfaceStyle::Outlined { color, width } => {
177            if interactive {
178                ShapeCommand::RippleOutlinedRect {
179                    color: *color,
180                    corner_radii,
181                    corner_g2,
182                    shadow: args.shadow,
183                    border_width: width.to_pixels_f32(),
184                    ripple: ripple_props,
185                }
186            } else {
187                ShapeCommand::OutlinedRect {
188                    color: *color,
189                    corner_radii,
190                    corner_g2,
191                    shadow: args.shadow,
192                    border_width: width.to_pixels_f32(),
193                }
194            }
195        }
196        SurfaceStyle::FilledOutlined {
197            fill_color,
198            border_color,
199            border_width,
200        } => {
201            if interactive {
202                ShapeCommand::RippleFilledOutlinedRect {
203                    color: *fill_color,
204                    border_color: *border_color,
205                    corner_radii,
206                    corner_g2,
207                    shadow: args.shadow,
208                    border_width: border_width.to_pixels_f32(),
209                    ripple: ripple_props,
210                }
211            } else {
212                ShapeCommand::FilledOutlinedRect {
213                    color: *fill_color,
214                    border_color: *border_color,
215                    corner_radii,
216                    corner_g2,
217                    shadow: args.shadow,
218                    border_width: border_width.to_pixels_f32(),
219                }
220            }
221        }
222    }
223}
224
225fn build_ellipse_command(
226    args: &SurfaceArgs,
227    style: &SurfaceStyle,
228    ripple_props: RippleProps,
229    interactive: bool,
230) -> ShapeCommand {
231    let corner_marker = [-1.0, -1.0, -1.0, -1.0];
232    match style {
233        SurfaceStyle::Filled { color } => {
234            if interactive {
235                ShapeCommand::RippleRect {
236                    color: *color,
237                    corner_radii: corner_marker,
238                    corner_g2: [0.0; 4],
239                    shadow: args.shadow,
240                    ripple: ripple_props,
241                }
242            } else {
243                ShapeCommand::Ellipse {
244                    color: *color,
245                    shadow: args.shadow,
246                }
247            }
248        }
249        SurfaceStyle::Outlined { color, width } => {
250            if interactive {
251                ShapeCommand::RippleOutlinedRect {
252                    color: *color,
253                    corner_radii: corner_marker,
254                    corner_g2: [0.0; 4],
255                    shadow: args.shadow,
256                    border_width: width.to_pixels_f32(),
257                    ripple: ripple_props,
258                }
259            } else {
260                ShapeCommand::OutlinedEllipse {
261                    color: *color,
262                    shadow: args.shadow,
263                    border_width: width.to_pixels_f32(),
264                }
265            }
266        }
267        SurfaceStyle::FilledOutlined {
268            fill_color,
269            border_color,
270            border_width,
271        } => {
272            // NOTE: No ripple variant for FilledOutlinedEllipse yet.
273            ShapeCommand::FilledOutlinedEllipse {
274                color: *fill_color,
275                border_color: *border_color,
276                shadow: args.shadow,
277                border_width: border_width.to_pixels_f32(),
278            }
279        }
280    }
281}
282
283fn build_shape_command(
284    args: &SurfaceArgs,
285    style: &SurfaceStyle,
286    ripple_props: RippleProps,
287    size: PxSize,
288) -> ShapeCommand {
289    let interactive = args.on_click.is_some();
290
291    match args.shape.resolve_for_size(size) {
292        ResolvedShape::Rounded {
293            corner_radii,
294            corner_g2,
295        } => build_rounded_rectangle_command(
296            args,
297            style,
298            ripple_props,
299            corner_radii,
300            corner_g2,
301            interactive,
302        ),
303        ResolvedShape::Ellipse => build_ellipse_command(args, style, ripple_props, interactive),
304    }
305}
306
307fn make_surface_drawable(
308    args: &SurfaceArgs,
309    style: &SurfaceStyle,
310    ripple_state: Option<&RippleState>,
311    size: PxSize,
312) -> ShapeCommand {
313    let ripple_props = build_ripple_props(args, ripple_state);
314    build_shape_command(args, style, ripple_props, size)
315}
316
317fn try_build_simple_rect_command(
318    args: &SurfaceArgs,
319    style: &SurfaceStyle,
320    ripple_state: Option<&RippleState>,
321) -> Option<SimpleRectCommand> {
322    if args.shadow.is_some() {
323        return None;
324    }
325    if args.on_click.is_some() {
326        return None;
327    }
328    if let Some(state) = ripple_state
329        && state.get_animation_progress().is_some()
330    {
331        return None;
332    }
333
334    let color = match style {
335        SurfaceStyle::Filled { color } => *color,
336        _ => return None,
337    };
338
339    match args.shape {
340        Shape::RoundedRectangle {
341            top_left,
342            top_right,
343            bottom_right,
344            bottom_left,
345            ..
346        } => {
347            let corners = [top_left, top_right, bottom_right, bottom_left];
348            if corners
349                .iter()
350                .any(|corner| matches!(corner, RoundedCorner::Capsule))
351            {
352                return None;
353            }
354
355            let zero_eps = 0.0001;
356            if corners.iter().all(|corner| match corner {
357                RoundedCorner::Manual { radius, .. } => radius.to_pixels_f32().abs() <= zero_eps,
358                RoundedCorner::Capsule => false,
359            }) {
360                Some(SimpleRectCommand { color })
361            } else {
362                None
363            }
364        }
365        _ => None,
366    }
367}
368
369fn compute_surface_size(
370    effective_surface_constraint: Constraint,
371    child_measurement: ComputedData,
372    padding_px: Px,
373) -> (Px, Px) {
374    let min_width = child_measurement.width + padding_px * 2;
375    let min_height = child_measurement.height + padding_px * 2;
376
377    fn clamp_wrap(min: Option<Px>, max: Option<Px>, min_measure: Px) -> Px {
378        min.unwrap_or(Px(0))
379            .max(min_measure)
380            .min(max.unwrap_or(Px::MAX))
381    }
382
383    fn fill_value(min: Option<Px>, max: Option<Px>, min_measure: Px) -> Px {
384        max.expect("Seems that you are trying to fill an infinite dimension, which is not allowed")
385            .max(min_measure)
386            .max(min.unwrap_or(Px(0)))
387    }
388
389    let width = match effective_surface_constraint.width {
390        DimensionValue::Fixed(value) => value,
391        DimensionValue::Wrap { min, max } => clamp_wrap(min, max, min_width),
392        DimensionValue::Fill { min, max } => fill_value(min, max, min_width),
393    };
394
395    let height = match effective_surface_constraint.height {
396        DimensionValue::Fixed(value) => value,
397        DimensionValue::Wrap { min, max } => clamp_wrap(min, max, min_height),
398        DimensionValue::Fill { min, max } => fill_value(min, max, min_height),
399    };
400
401    (width, height)
402}
403
404/// # surface
405///
406/// Renders a styled container for content with optional interaction.
407///
408/// ## Usage
409///
410/// Wrap content to provide a visual background, shape, and optional click handling with a ripple effect.
411///
412/// ## Parameters
413///
414/// - `args` — configures the surface's appearance, layout, and interaction; see [`SurfaceArgs`].
415/// - `ripple_state` — an optional, clonable [`RippleState`] to enable and manage the ripple animation on click.
416/// - `child` — a closure that renders the content inside the surface.
417///
418/// ## Examples
419///
420/// ```
421/// use std::sync::Arc;
422/// use tessera_ui::{Dp, Color};
423/// use tessera_ui_basic_components::{
424///     surface::{surface, SurfaceArgsBuilder},
425///     ripple_state::RippleState,
426///     text::{text, TextArgsBuilder},
427/// };
428///
429/// let ripple = RippleState::new();
430///
431/// surface(
432///     SurfaceArgsBuilder::default()
433///         .padding(Dp(16.0))
434///         .on_click(Arc::new(|| println!("Surface was clicked!")))
435///         .build()
436///         .unwrap(),
437///     Some(ripple),
438///     || {
439///         text(TextArgsBuilder::default().text("Click me".to_string()).build().expect("builder construction failed"));
440///     },
441/// );
442/// ```
443#[tessera]
444pub fn surface(args: SurfaceArgs, ripple_state: Option<RippleState>, child: impl FnOnce()) {
445    (child)();
446    let ripple_state_for_measure = ripple_state.clone();
447    let args_measure_clone = args.clone();
448    let args_for_handler = args.clone();
449
450    measure(Box::new(move |input| {
451        let surface_intrinsic_width = args_measure_clone.width;
452        let surface_intrinsic_height = args_measure_clone.height;
453        let surface_intrinsic_constraint =
454            Constraint::new(surface_intrinsic_width, surface_intrinsic_height);
455        let effective_surface_constraint =
456            surface_intrinsic_constraint.merge(input.parent_constraint);
457        let padding_px: Px = args_measure_clone.padding.into();
458        let child_constraint = Constraint::new(
459            remove_padding_from_dimension(effective_surface_constraint.width, padding_px),
460            remove_padding_from_dimension(effective_surface_constraint.height, padding_px),
461        );
462
463        let child_measurement = if !input.children_ids.is_empty() {
464            let child_measurements = input.measure_children(
465                input
466                    .children_ids
467                    .iter()
468                    .copied()
469                    .map(|node_id| (node_id, child_constraint))
470                    .collect(),
471            )?;
472            input.place_child(
473                input.children_ids[0],
474                PxPosition {
475                    x: args.padding.into(),
476                    y: args.padding.into(),
477                },
478            );
479            let mut max_width = Px::ZERO;
480            let mut max_height = Px::ZERO;
481            for measurement in child_measurements.values() {
482                max_width = max_width.max(measurement.width);
483                max_height = max_height.max(measurement.height);
484            }
485            ComputedData {
486                width: max_width,
487                height: max_height,
488            }
489        } else {
490            ComputedData {
491                width: Px(0),
492                height: Px(0),
493            }
494        };
495
496        let is_hovered = ripple_state_for_measure
497            .as_ref()
498            .map(|state| state.is_hovered())
499            .unwrap_or(false);
500
501        let effective_style = args_measure_clone
502            .hover_style
503            .as_ref()
504            .filter(|_| is_hovered)
505            .unwrap_or(&args_measure_clone.style);
506
507        let padding_px: Px = args_measure_clone.padding.into();
508        let (width, height) =
509            compute_surface_size(effective_surface_constraint, child_measurement, padding_px);
510
511        if let Some(simple) = try_build_simple_rect_command(
512            &args_measure_clone,
513            effective_style,
514            ripple_state_for_measure.as_ref(),
515        ) {
516            input.metadata_mut().push_draw_command(simple);
517        } else {
518            let drawable = make_surface_drawable(
519                &args_measure_clone,
520                effective_style,
521                ripple_state_for_measure.as_ref(),
522                PxSize::new(width, height),
523            );
524
525            input.metadata_mut().push_draw_command(drawable);
526        }
527
528        Ok(ComputedData { width, height })
529    }));
530
531    if args.on_click.is_some() {
532        let args_for_handler = args.clone();
533        let state_for_handler = ripple_state;
534        input_handler(Box::new(move |mut input| {
535            // Apply accessibility metadata first
536            apply_surface_accessibility(
537                &mut input,
538                &args_for_handler,
539                true,
540                args_for_handler.on_click.clone(),
541            );
542
543            // Then handle interactive behavior
544            let size = input.computed_data;
545            let cursor_pos_option = input.cursor_position_rel;
546            let is_cursor_in_surface = cursor_pos_option
547                .map(|pos| is_position_in_component(size, pos))
548                .unwrap_or(false);
549
550            if let Some(ref state) = state_for_handler {
551                state.set_hovered(is_cursor_in_surface);
552            }
553
554            if is_cursor_in_surface && args_for_handler.on_click.is_some() {
555                input.requests.cursor_icon = CursorIcon::Pointer;
556            }
557
558            if is_cursor_in_surface {
559                let press_events: Vec<_> = input
560                    .cursor_events
561                    .iter()
562                    .filter(|event| {
563                        matches!(
564                            event.content,
565                            CursorEventContent::Pressed(PressKeyEventType::Left)
566                        )
567                    })
568                    .collect();
569
570                let release_events: Vec<_> = input
571                    .cursor_events
572                    .iter()
573                    .filter(|event| event.gesture_state == GestureState::TapCandidate)
574                    .filter(|event| {
575                        matches!(
576                            event.content,
577                            CursorEventContent::Released(PressKeyEventType::Left)
578                        )
579                    })
580                    .collect();
581
582                if !press_events.is_empty()
583                    && let (Some(cursor_pos), Some(state)) =
584                        (cursor_pos_option, state_for_handler.as_ref())
585                {
586                    let normalized_x = (cursor_pos.x.to_f32() / size.width.to_f32()) - 0.5;
587                    let normalized_y = (cursor_pos.y.to_f32() / size.height.to_f32()) - 0.5;
588
589                    state.start_animation([normalized_x, normalized_y]);
590                }
591
592                if !release_events.is_empty()
593                    && let Some(ref on_click) = args_for_handler.on_click
594                {
595                    on_click();
596                }
597
598                if args_for_handler.block_input {
599                    input.block_all();
600                }
601            }
602        }));
603    } else {
604        input_handler(Box::new(move |mut input| {
605            // Apply accessibility metadata first
606            apply_surface_accessibility(&mut input, &args_for_handler, false, None);
607
608            // Then handle input blocking if needed
609            let size = input.computed_data;
610            let cursor_pos_option = input.cursor_position_rel;
611            let is_cursor_in_surface = cursor_pos_option
612                .map(|pos| is_position_in_component(size, pos))
613                .unwrap_or(false);
614            if args_for_handler.block_input && is_cursor_in_surface {
615                input.block_all();
616            }
617        }));
618    }
619}
620
621fn apply_surface_accessibility(
622    input: &mut InputHandlerInput<'_>,
623    args: &SurfaceArgs,
624    interactive: bool,
625    on_click: Option<Arc<dyn Fn() + Send + Sync>>,
626) {
627    let has_metadata = args.accessibility_role.is_some()
628        || args.accessibility_label.is_some()
629        || args.accessibility_description.is_some()
630        || args.accessibility_focusable
631        || interactive;
632
633    if !has_metadata {
634        return;
635    }
636
637    let mut builder = input.accessibility();
638
639    let role = args
640        .accessibility_role
641        .or_else(|| interactive.then_some(Role::Button));
642    if let Some(role) = role {
643        builder = builder.role(role);
644    }
645    if let Some(label) = args.accessibility_label.as_ref() {
646        builder = builder.label(label.clone());
647    }
648    if let Some(description) = args.accessibility_description.as_ref() {
649        builder = builder.description(description.clone());
650    }
651    if args.accessibility_focusable || interactive {
652        builder = builder.focusable();
653    }
654    if interactive {
655        builder = builder.action(Action::Click);
656    }
657    builder.commit();
658
659    if interactive && let Some(on_click) = on_click {
660        input.set_accessibility_action_handler(move |action| {
661            if action == Action::Click {
662                on_click();
663            }
664        });
665    }
666}