tessera_ui_basic_components/
surface.rs

1use std::sync::Arc;
2
3use derive_builder::Builder;
4use tessera_ui::{
5    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, PressKeyEventType, Px,
6    PxPosition, measure_node, place_node, winit::window::CursorIcon,
7};
8use tessera_ui_macros::tessera;
9
10use crate::{
11    padding_utils::remove_padding_from_dimension,
12    pipelines::{RippleProps, ShadowProps, ShapeCommand},
13    pos_misc::is_position_in_component,
14    ripple_state::RippleState,
15    shape_def::Shape,
16};
17
18/// Arguments for the `surface` component.
19#[derive(Builder, Clone)]
20#[builder(pattern = "owned")]
21pub struct SurfaceArgs {
22    /// The fill color of the surface (RGBA).
23    #[builder(default = "Color::new(0.4745, 0.5255, 0.7961, 1.0)")]
24    pub color: Color,
25    /// The hover color of the surface (RGBA). If None, no hover effect is applied.
26    #[builder(default)]
27    pub hover_color: Option<Color>,
28    /// The shape of the surface.
29    #[builder(default)]
30    pub shape: Shape,
31    /// The shadow properties of the surface.
32    #[builder(default)]
33    pub shadow: Option<ShadowProps>,
34    /// The padding of the surface.
35    #[builder(default = "Dp(0.0)")]
36    pub padding: Dp,
37    /// Optional explicit width behavior for the surface. Defaults to Wrap {min: None, max: None} if None.
38    #[builder(default, setter(strip_option))]
39    pub width: Option<DimensionValue>,
40    /// Optional explicit height behavior for the surface. Defaults to Wrap {min: None, max: None} if None.
41    #[builder(default, setter(strip_option))]
42    pub height: Option<DimensionValue>,
43    /// Width of the border. If > 0, an outline will be drawn.
44    #[builder(default = "0.0")]
45    pub border_width: f32,
46    /// Optional color for the border (RGBA). If None and border_width > 0, `color` will be used.
47    #[builder(default)]
48    pub border_color: Option<Color>,
49    /// Optional click callback function. If provided, surface becomes interactive with ripple effect.
50    #[builder(default)]
51    pub on_click: Option<Arc<dyn Fn() + Send + Sync>>,
52    /// The ripple color (RGB) for interactive surfaces.
53    #[builder(default = "Color::from_rgb(1.0, 1.0, 1.0)")]
54    pub ripple_color: Color,
55}
56
57// Manual implementation of Default because derive_builder's default conflicts with our specific defaults
58impl Default for SurfaceArgs {
59    fn default() -> Self {
60        SurfaceArgsBuilder::default().build().unwrap()
61    }
62}
63
64/// surface component, a basic container that can have its own size constraints.
65/// If args contains an on_click callback, a ripple_state must be provided for interactive behavior.
66#[tessera]
67pub fn surface(args: SurfaceArgs, ripple_state: Option<Arc<RippleState>>, child: impl FnOnce()) {
68    (child)();
69    let ripple_state_for_measure = ripple_state.clone();
70    let args_measure_clone = args.clone();
71
72    measure(Box::new(move |input| {
73        // Determine surface's intrinsic constraint based on args
74        let surface_intrinsic_width = args_measure_clone.width.unwrap_or(DimensionValue::Wrap {
75            min: None,
76            max: None,
77        });
78        let surface_intrinsic_height = args_measure_clone.height.unwrap_or(DimensionValue::Wrap {
79            min: None,
80            max: None,
81        });
82        let surface_intrinsic_constraint =
83            Constraint::new(surface_intrinsic_width, surface_intrinsic_height);
84        // Merge with parent_constraint to get effective_surface_constraint
85        let effective_surface_constraint =
86            surface_intrinsic_constraint.merge(input.parent_constraint);
87        // Determine constraint for the child
88        let child_constraint = Constraint::new(
89            remove_padding_from_dimension(
90                effective_surface_constraint.width,
91                args_measure_clone.padding.into(),
92            ),
93            remove_padding_from_dimension(
94                effective_surface_constraint.height,
95                args_measure_clone.padding.into(),
96            ),
97        );
98        // Measure the child with the computed constraint
99        let child_measurement = if !input.children_ids.is_empty() {
100            let child_measurement = measure_node(
101                input.children_ids[0],
102                &child_constraint,
103                input.tree,
104                input.metadatas,
105                input.compute_resource_manager.clone(),
106                input.gpu,
107            )?;
108            // place the child
109            place_node(
110                input.children_ids[0],
111                PxPosition {
112                    x: args.padding.into(),
113                    y: args.padding.into(),
114                },
115                input.metadatas,
116            );
117            child_measurement
118        } else {
119            ComputedData {
120                width: Px(0),
121                height: Px(0),
122            }
123        };
124        // Add drawable for the surface
125        let is_hovered = ripple_state_for_measure
126            .as_ref()
127            .map(|state| state.is_hovered())
128            .unwrap_or(false);
129
130        let effective_color = if is_hovered && args_measure_clone.hover_color.is_some() {
131            args_measure_clone.hover_color.unwrap()
132        } else {
133            args_measure_clone.color
134        };
135
136        let drawable = if args_measure_clone.on_click.is_some() {
137            // Interactive surface with ripple effect
138            let ripple_props = if let Some(ref state) = ripple_state_for_measure {
139                if let Some((progress, click_pos)) = state.get_animation_progress() {
140                    let radius = progress; // Expand from 0 to 1
141                    let alpha = (1.0 - progress) * 0.3; // Fade out
142
143                    RippleProps {
144                        center: click_pos,
145                        radius,
146                        alpha,
147                        color: args_measure_clone.ripple_color,
148                    }
149                } else {
150                    RippleProps::default()
151                }
152            } else {
153                RippleProps::default()
154            };
155
156            match args_measure_clone.shape {
157                Shape::RoundedRectangle {
158                    corner_radius,
159                    g2_k_value,
160                } => {
161                    if args_measure_clone.border_width > 0.0 {
162                        ShapeCommand::RippleOutlinedRect {
163                            color: args_measure_clone.border_color.unwrap_or(effective_color),
164                            corner_radius,
165                            g2_k_value,
166                            shadow: args_measure_clone.shadow,
167                            border_width: args_measure_clone.border_width,
168                            ripple: ripple_props,
169                        }
170                    } else {
171                        ShapeCommand::RippleRect {
172                            color: effective_color,
173                            corner_radius,
174                            g2_k_value,
175                            shadow: args_measure_clone.shadow,
176                            ripple: ripple_props,
177                        }
178                    }
179                }
180                Shape::Ellipse => {
181                    if args_measure_clone.border_width > 0.0 {
182                        ShapeCommand::RippleOutlinedRect {
183                            color: args_measure_clone.border_color.unwrap_or(effective_color),
184                            corner_radius: -1.0, // Use negative radius to signify ellipse
185                            g2_k_value: 0.0,     // Just for compatibility, not used in ellipse
186                            shadow: args_measure_clone.shadow,
187                            border_width: args_measure_clone.border_width,
188                            ripple: ripple_props,
189                        }
190                    } else {
191                        ShapeCommand::RippleRect {
192                            color: effective_color,
193                            corner_radius: -1.0, // Use negative radius to signify ellipse
194                            g2_k_value: 0.0,     // Just for compatibility, not used in ellipse
195                            shadow: args_measure_clone.shadow,
196                            ripple: ripple_props,
197                        }
198                    }
199                }
200            }
201        } else {
202            // Non-interactive surface
203            match args_measure_clone.shape {
204                Shape::RoundedRectangle {
205                    corner_radius,
206                    g2_k_value,
207                } => {
208                    if args_measure_clone.border_width > 0.0 {
209                        ShapeCommand::OutlinedRect {
210                            color: args_measure_clone.border_color.unwrap_or(effective_color),
211                            corner_radius,
212                            g2_k_value,
213                            shadow: args_measure_clone.shadow,
214                            border_width: args_measure_clone.border_width,
215                        }
216                    } else {
217                        ShapeCommand::Rect {
218                            color: effective_color,
219                            corner_radius,
220                            g2_k_value,
221                            shadow: args_measure_clone.shadow,
222                        }
223                    }
224                }
225                Shape::Ellipse => {
226                    if args_measure_clone.border_width > 0.0 {
227                        ShapeCommand::OutlinedEllipse {
228                            color: args_measure_clone.border_color.unwrap_or(effective_color),
229                            shadow: args_measure_clone.shadow,
230                            border_width: args_measure_clone.border_width,
231                        }
232                    } else {
233                        ShapeCommand::Ellipse {
234                            color: effective_color,
235                            shadow: args_measure_clone.shadow,
236                        }
237                    }
238                }
239            }
240        };
241
242        if let Some(mut metadata) = input.metadatas.get_mut(&input.current_node_id) {
243            metadata.push_draw_command(drawable);
244        }
245
246        // Calculate the final size of the surface
247        let padding_px: Px = args_measure_clone.padding.into();
248        let min_width = child_measurement.width + padding_px * 2;
249        let min_height = child_measurement.height + padding_px * 2;
250        let width = match effective_surface_constraint.width {
251            DimensionValue::Fixed(value) => value,
252            DimensionValue::Wrap { min, max } => min
253                .unwrap_or(Px(0))
254                .max(min_width)
255                .min(max.unwrap_or(Px::MAX)),
256            DimensionValue::Fill { min, max } => max
257                .expect("Seems that you are trying to fill an infinite width, which is not allowed")
258                .max(min_height)
259                .max(min.unwrap_or(Px(0))),
260        };
261        let height = match effective_surface_constraint.height {
262            DimensionValue::Fixed(value) => value,
263            DimensionValue::Wrap { min, max } => min
264                .unwrap_or(Px(0))
265                .max(min_height)
266                .min(max.unwrap_or(Px::MAX)),
267            DimensionValue::Fill { min, max } => max
268                .expect(
269                    "Seems that you are trying to fill an infinite height, which is not allowed",
270                )
271                .max(min_height)
272                .max(min.unwrap_or(Px(0))),
273        };
274        Ok(ComputedData { width, height })
275    }));
276
277    // Event handling for interactive surfaces
278    if args.on_click.is_some() {
279        let args_for_handler = args.clone();
280        let state_for_handler = ripple_state;
281        state_handler(Box::new(move |input| {
282            let size = input.computed_data;
283            let cursor_pos_option = input.cursor_position;
284            let is_cursor_in_surface = cursor_pos_option
285                .map(|pos| is_position_in_component(size, pos))
286                .unwrap_or(false);
287
288            // Update hover state
289            if let Some(ref state) = state_for_handler {
290                state.set_hovered(is_cursor_in_surface);
291            }
292
293            // Set cursor to pointer if hovered and clickable
294            if is_cursor_in_surface && args_for_handler.on_click.is_some() {
295                input.requests.cursor_icon = CursorIcon::Pointer;
296            }
297
298            // Handle mouse events
299            if is_cursor_in_surface {
300                // Check for mouse press events to start ripple
301                let press_events: Vec<_> = input
302                    .cursor_events
303                    .iter()
304                    .filter(|event| {
305                        matches!(
306                            event.content,
307                            CursorEventContent::Pressed(PressKeyEventType::Left)
308                        )
309                    })
310                    .collect();
311
312                // Check for mouse release events (click)
313                let release_events: Vec<_> = input
314                    .cursor_events
315                    .iter()
316                    .filter(|event| {
317                        matches!(
318                            event.content,
319                            CursorEventContent::Released(PressKeyEventType::Left)
320                        )
321                    })
322                    .collect();
323
324                if !press_events.is_empty()
325                    && let (Some(cursor_pos), Some(state)) =
326                        (cursor_pos_option, state_for_handler.as_ref())
327                {
328                    // Convert cursor position to normalized coordinates [-0.5, 0.5]
329                    let normalized_x = (cursor_pos.x.to_f32() / size.width.to_f32()) - 0.5;
330                    let normalized_y = (cursor_pos.y.to_f32() / size.height.to_f32()) - 0.5;
331
332                    // Start ripple animation
333                    state.start_animation([normalized_x, normalized_y]);
334                }
335
336                if !release_events.is_empty() {
337                    // Trigger click callback
338                    if let Some(ref on_click) = args_for_handler.on_click {
339                        on_click();
340                    }
341                }
342
343                // Consume cursor events if we're handling relevant mouse events
344                if !press_events.is_empty() || !release_events.is_empty() {
345                    input.cursor_events.clear();
346                }
347            }
348        }));
349    }
350}