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