tessera_ui_basic_components/
surface.rs

1//! Provides a flexible, customizable surface container component for UI elements.
2//!
3//! This module defines the [`surface`] component and its configuration via [`SurfaceArgs`].
4//! The surface acts as a visual and interactive container, supporting background color,
5//! shape, shadow, border, padding, and optional ripple effects for user interaction.
6//!
7//! Typical use cases include wrapping content to visually separate it from the background,
8//! providing elevation or emphasis, and enabling interactive feedback (e.g., ripple on click).
9//! It is commonly used as the foundational layer for buttons, dialogs, editors, and other
10//! interactive or visually distinct UI elements.
11//!
12//! The surface can be configured for both static and interactive scenarios, with support for
13//! hover and click callbacks, making it suitable for a wide range of UI composition needs.
14
15use std::sync::Arc;
16
17use derive_builder::Builder;
18use tessera_ui::{
19    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, PressKeyEventType, Px,
20    PxPosition, winit::window::CursorIcon,
21};
22use tessera_ui_macros::tessera;
23
24use crate::{
25    padding_utils::remove_padding_from_dimension,
26    pipelines::{RippleProps, ShadowProps, ShapeCommand},
27    pos_misc::is_position_in_component,
28    ripple_state::RippleState,
29    shape_def::Shape,
30};
31
32///
33/// Arguments for the [`surface`] component.
34///
35/// This struct defines the configurable properties for the [`surface`] container,
36/// which provides a background, optional shadow, border, shape, and interactive
37/// ripple effect. The surface is commonly used to wrap content and visually
38/// separate it from the background or other UI elements.
39///
40/// # Fields
41///
42/// - `color`: The fill color of the surface (RGBA). Defaults to a blue-gray.
43/// - `hover_color`: The color displayed when the surface is hovered. If `None`, no hover effect is applied.
44/// - `shape`: The geometric shape of the surface (e.g., rounded rectangle, ellipse).
45/// - `shadow`: Optional shadow properties for elevation effects.
46/// - `padding`: Padding inside the surface, applied to all sides.
47/// - `width`: Optional explicit width constraint. If `None`, wraps content.
48/// - `height`: Optional explicit height constraint. If `None`, wraps content.
49/// - `border_width`: Width of the border. If greater than 0, an outline is drawn.
50/// - `border_color`: Optional color for the border. If `None` and `border_width > 0`, uses `color`.
51/// - `on_click`: Optional callback for click events. If set, the surface becomes interactive and shows a ripple effect.
52/// - `ripple_color`: The color of the ripple effect for interactive surfaces.
53///
54/// # Example
55///
56/// ```
57/// use std::sync::Arc;
58/// use tessera_ui::{Color, Dp};
59/// use tessera_ui_basic_components::{
60///     pipelines::ShadowProps,
61///     ripple_state::RippleState,
62///     surface::{surface, SurfaceArgs},
63/// };
64///
65/// let ripple_state = Arc::new(RippleState::new());
66/// surface(
67///     SurfaceArgs {
68///         color: Color::from_rgb(0.95, 0.95, 1.0),
69///         shadow: Some(ShadowProps::default()),
70///         padding: Dp(16.0),
71///         border_width: 1.0,
72///         border_color: Some(Color::from_rgb(0.7, 0.7, 0.9)),
73///         ..Default::default()
74///     },
75///     Some(ripple_state.clone()),
76///     || {},
77/// );
78/// ```
79#[derive(Builder, Clone)]
80#[builder(pattern = "owned")]
81pub struct SurfaceArgs {
82    /// The fill color of the surface (RGBA).
83    #[builder(default = "Color::new(0.4745, 0.5255, 0.7961, 1.0)")]
84    pub color: Color,
85    /// The hover color of the surface (RGBA). If None, no hover effect is applied.
86    #[builder(default)]
87    pub hover_color: Option<Color>,
88    /// The shape of the surface (e.g., rounded rectangle, ellipse).
89    #[builder(default)]
90    pub shape: Shape,
91    /// The shadow properties of the surface.
92    #[builder(default)]
93    pub shadow: Option<ShadowProps>,
94    /// The padding inside the surface.
95    #[builder(default = "Dp(0.0)")]
96    pub padding: Dp,
97    /// Optional explicit width behavior for the surface. Defaults to Wrap {min: None, max: None} if None.
98    #[builder(default, setter(strip_option))]
99    pub width: Option<DimensionValue>,
100    /// Optional explicit height behavior for the surface. Defaults to Wrap {min: None, max: None} if None.
101    #[builder(default, setter(strip_option))]
102    pub height: Option<DimensionValue>,
103    /// Width of the border. If > 0, an outline will be drawn.
104    #[builder(default = "0.0")]
105    pub border_width: f32,
106    /// Optional color for the border (RGBA). If None and border_width > 0, `color` will be used.
107    #[builder(default)]
108    pub border_color: Option<Color>,
109    /// Optional click callback function. If provided, surface becomes interactive with ripple effect.
110    #[builder(default)]
111    pub on_click: Option<Arc<dyn Fn() + Send + Sync>>,
112    /// The ripple color (RGB) for interactive surfaces.
113    #[builder(default = "Color::from_rgb(1.0, 1.0, 1.0)")]
114    pub ripple_color: Color,
115    /// Whether the surface should block all input events.
116    #[builder(default = "false")]
117    pub block_input: bool,
118}
119
120// Manual implementation of Default because derive_builder's default conflicts with our specific defaults
121impl Default for SurfaceArgs {
122    fn default() -> Self {
123        SurfaceArgsBuilder::default().build().unwrap()
124    }
125}
126
127///
128/// A basic container component that provides a customizable background, optional shadow,
129/// border, shape, and interactive ripple effect. The surface is typically used to wrap
130/// content and visually separate it from the background or other UI elements.
131///
132/// If `args.on_click` is set, the surface becomes interactive and displays a ripple
133/// animation on click. In this case, a [`RippleState`] must be provided to manage
134/// the ripple effect and hover state.
135///
136/// # Parameters
137///
138/// - `args`: [`SurfaceArgs`] struct specifying appearance, layout, and interaction.
139/// - `ripple_state`: Optional [`RippleState`] for interactive surfaces. Required if `on_click` is set.
140/// - `child`: Closure that builds the child content inside the surface.
141///
142/// # Example
143///
144/// ```
145/// use std::sync::Arc;
146/// use tessera_ui::{Color, Dp};
147/// use tessera_ui_basic_components::{
148///     pipelines::ShadowProps,
149///     surface::{surface, SurfaceArgs},
150///     text::text,
151/// };
152///
153/// surface(
154///     SurfaceArgs {
155///         color: Color::from_rgb(1.0, 1.0, 1.0),
156///         shadow: Some(ShadowProps::default()),
157///         padding: Dp(12.0),
158///         ..Default::default()
159///     },
160///     None,
161///     || {
162///         text("Content in a surface".to_string());
163///     },
164/// );
165/// ```
166///
167#[tessera]
168pub fn surface(args: SurfaceArgs, ripple_state: Option<Arc<RippleState>>, child: impl FnOnce()) {
169    (child)();
170    let ripple_state_for_measure = ripple_state.clone();
171    let args_measure_clone = args.clone();
172    let args_for_handler = args.clone();
173
174    measure(Box::new(move |input| {
175        // Determine surface's intrinsic constraint based on args
176        let surface_intrinsic_width = args_measure_clone.width.unwrap_or(DimensionValue::Wrap {
177            min: None,
178            max: None,
179        });
180        let surface_intrinsic_height = args_measure_clone.height.unwrap_or(DimensionValue::Wrap {
181            min: None,
182            max: None,
183        });
184        let surface_intrinsic_constraint =
185            Constraint::new(surface_intrinsic_width, surface_intrinsic_height);
186        // Merge with parent_constraint to get effective_surface_constraint
187        let effective_surface_constraint =
188            surface_intrinsic_constraint.merge(input.parent_constraint);
189        // Determine constraint for the child
190        let child_constraint = Constraint::new(
191            remove_padding_from_dimension(
192                effective_surface_constraint.width,
193                args_measure_clone.padding.into(),
194            ),
195            remove_padding_from_dimension(
196                effective_surface_constraint.height,
197                args_measure_clone.padding.into(),
198            ),
199        );
200        // Measure the child with the computed constraint
201        let child_measurement = if !input.children_ids.is_empty() {
202            let child_measurement =
203                input.measure_child(input.children_ids[0], &child_constraint)?;
204            // place the child
205            input.place_child(
206                input.children_ids[0],
207                PxPosition {
208                    x: args.padding.into(),
209                    y: args.padding.into(),
210                },
211            );
212            child_measurement
213        } else {
214            ComputedData {
215                width: Px(0),
216                height: Px(0),
217            }
218        };
219        // Add drawable for the surface
220        let is_hovered = ripple_state_for_measure
221            .as_ref()
222            .map(|state| state.is_hovered())
223            .unwrap_or(false);
224
225        let effective_color = if is_hovered && args_measure_clone.hover_color.is_some() {
226            args_measure_clone.hover_color.unwrap()
227        } else {
228            args_measure_clone.color
229        };
230
231        let drawable = if args_measure_clone.on_click.is_some() {
232            // Interactive surface with ripple effect
233            let ripple_props = if let Some(ref state) = ripple_state_for_measure {
234                if let Some((progress, click_pos)) = state.get_animation_progress() {
235                    let radius = progress; // Expand from 0 to 1
236                    let alpha = (1.0 - progress) * 0.3; // Fade out
237
238                    RippleProps {
239                        center: click_pos,
240                        radius,
241                        alpha,
242                        color: args_measure_clone.ripple_color,
243                    }
244                } else {
245                    RippleProps::default()
246                }
247            } else {
248                RippleProps::default()
249            };
250
251            match args_measure_clone.shape {
252                Shape::RoundedRectangle {
253                    corner_radius,
254                    g2_k_value,
255                } => {
256                    if args_measure_clone.border_width > 0.0 {
257                        ShapeCommand::RippleOutlinedRect {
258                            color: args_measure_clone.border_color.unwrap_or(effective_color),
259                            corner_radius,
260                            g2_k_value,
261                            shadow: args_measure_clone.shadow,
262                            border_width: args_measure_clone.border_width,
263                            ripple: ripple_props,
264                        }
265                    } else {
266                        ShapeCommand::RippleRect {
267                            color: effective_color,
268                            corner_radius,
269                            g2_k_value,
270                            shadow: args_measure_clone.shadow,
271                            ripple: ripple_props,
272                        }
273                    }
274                }
275                Shape::Ellipse => {
276                    if args_measure_clone.border_width > 0.0 {
277                        ShapeCommand::RippleOutlinedRect {
278                            color: args_measure_clone.border_color.unwrap_or(effective_color),
279                            corner_radius: -1.0, // Use negative radius to signify ellipse
280                            g2_k_value: 0.0,     // Just for compatibility, not used in ellipse
281                            shadow: args_measure_clone.shadow,
282                            border_width: args_measure_clone.border_width,
283                            ripple: ripple_props,
284                        }
285                    } else {
286                        ShapeCommand::RippleRect {
287                            color: effective_color,
288                            corner_radius: -1.0, // Use negative radius to signify ellipse
289                            g2_k_value: 0.0,     // Just for compatibility, not used in ellipse
290                            shadow: args_measure_clone.shadow,
291                            ripple: ripple_props,
292                        }
293                    }
294                }
295            }
296        } else {
297            // Non-interactive surface
298            match args_measure_clone.shape {
299                Shape::RoundedRectangle {
300                    corner_radius,
301                    g2_k_value,
302                } => {
303                    if args_measure_clone.border_width > 0.0 {
304                        ShapeCommand::OutlinedRect {
305                            color: args_measure_clone.border_color.unwrap_or(effective_color),
306                            corner_radius,
307                            g2_k_value,
308                            shadow: args_measure_clone.shadow,
309                            border_width: args_measure_clone.border_width,
310                        }
311                    } else {
312                        ShapeCommand::Rect {
313                            color: effective_color,
314                            corner_radius,
315                            g2_k_value,
316                            shadow: args_measure_clone.shadow,
317                        }
318                    }
319                }
320                Shape::Ellipse => {
321                    if args_measure_clone.border_width > 0.0 {
322                        ShapeCommand::OutlinedEllipse {
323                            color: args_measure_clone.border_color.unwrap_or(effective_color),
324                            shadow: args_measure_clone.shadow,
325                            border_width: args_measure_clone.border_width,
326                        }
327                    } else {
328                        ShapeCommand::Ellipse {
329                            color: effective_color,
330                            shadow: args_measure_clone.shadow,
331                        }
332                    }
333                }
334            }
335        };
336
337        input.metadata_mut().push_draw_command(drawable);
338
339        // Calculate the final size of the surface
340        let padding_px: Px = args_measure_clone.padding.into();
341        let min_width = child_measurement.width + padding_px * 2;
342        let min_height = child_measurement.height + padding_px * 2;
343        let width = match effective_surface_constraint.width {
344            DimensionValue::Fixed(value) => value,
345            DimensionValue::Wrap { min, max } => min
346                .unwrap_or(Px(0))
347                .max(min_width)
348                .min(max.unwrap_or(Px::MAX)),
349            DimensionValue::Fill { min, max } => max
350                .expect("Seems that you are trying to fill an infinite width, which is not allowed")
351                .max(min_width)
352                .max(min.unwrap_or(Px(0))),
353        };
354        let height = match effective_surface_constraint.height {
355            DimensionValue::Fixed(value) => value,
356            DimensionValue::Wrap { min, max } => min
357                .unwrap_or(Px(0))
358                .max(min_height)
359                .min(max.unwrap_or(Px::MAX)),
360            DimensionValue::Fill { min, max } => max
361                .expect(
362                    "Seems that you are trying to fill an infinite height, which is not allowed",
363                )
364                .max(min_height)
365                .max(min.unwrap_or(Px(0))),
366        };
367        Ok(ComputedData { width, height })
368    }));
369
370    // Event handling for interactive surfaces
371    if args.on_click.is_some() {
372        let args_for_handler = args.clone();
373        let state_for_handler = ripple_state;
374        state_handler(Box::new(move |mut input| {
375            let size = input.computed_data;
376            let cursor_pos_option = input.cursor_position_rel;
377            let is_cursor_in_surface = cursor_pos_option
378                .map(|pos| is_position_in_component(size, pos))
379                .unwrap_or(false);
380
381            // Update hover state
382            if let Some(ref state) = state_for_handler {
383                state.set_hovered(is_cursor_in_surface);
384            }
385
386            // Set cursor to pointer if hovered and clickable
387            if is_cursor_in_surface && args_for_handler.on_click.is_some() {
388                input.requests.cursor_icon = CursorIcon::Pointer;
389            }
390
391            // Handle mouse events
392            if is_cursor_in_surface {
393                // Check for mouse press events to start ripple
394                let press_events: Vec<_> = input
395                    .cursor_events
396                    .iter()
397                    .filter(|event| {
398                        matches!(
399                            event.content,
400                            CursorEventContent::Pressed(PressKeyEventType::Left)
401                        )
402                    })
403                    .collect();
404
405                // Check for mouse release events (click)
406                let release_events: Vec<_> = input
407                    .cursor_events
408                    .iter()
409                    .filter(|event| {
410                        matches!(
411                            event.content,
412                            CursorEventContent::Released(PressKeyEventType::Left)
413                        )
414                    })
415                    .collect();
416
417                if !press_events.is_empty()
418                    && let (Some(cursor_pos), Some(state)) =
419                        (cursor_pos_option, state_for_handler.as_ref())
420                {
421                    // Convert cursor position to normalized coordinates [-0.5, 0.5]
422                    let normalized_x = (cursor_pos.x.to_f32() / size.width.to_f32()) - 0.5;
423                    let normalized_y = (cursor_pos.y.to_f32() / size.height.to_f32()) - 0.5;
424
425                    // Start ripple animation
426                    state.start_animation([normalized_x, normalized_y]);
427                }
428
429                if !release_events.is_empty() {
430                    // Trigger click callback
431                    if let Some(ref on_click) = args_for_handler.on_click {
432                        on_click();
433                    }
434                }
435
436                // Block all events to prevent propagation
437                if args_for_handler.block_input {
438                    input.block_all();
439                }
440            }
441        }));
442    } else {
443        // Non-interactive surface, still block all cursor events inside the surface
444        state_handler(Box::new(move |mut input| {
445            let size = input.computed_data;
446            let cursor_pos_option = input.cursor_position_rel;
447            let is_cursor_in_surface = cursor_pos_option
448                .map(|pos| is_position_in_component(size, pos))
449                .unwrap_or(false);
450            if args_for_handler.block_input && is_cursor_in_surface {
451                input.block_all();
452            }
453        }));
454    }
455}