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, PxSize, tessera, winit::window::CursorIcon,
21};
22
23use crate::{
24    padding_utils::remove_padding_from_dimension,
25    pipelines::{RippleProps, ShadowProps, ShapeCommand},
26    pos_misc::is_position_in_component,
27    ripple_state::RippleState,
28    shape_def::Shape,
29};
30
31/// Defines the visual style of the surface (fill, outline, or both).
32#[derive(Clone)]
33pub enum SurfaceStyle {
34    /// A solid color fill.
35    Filled { color: Color },
36    /// A solid color outline with a transparent fill.
37    Outlined { color: Color, width: Dp },
38    /// A solid color fill with a solid color outline.
39    FilledOutlined {
40        fill_color: Color,
41        border_color: Color,
42        border_width: Dp,
43    },
44}
45
46impl Default for SurfaceStyle {
47    fn default() -> Self {
48        SurfaceStyle::Filled {
49            color: Color::new(0.4745, 0.5255, 0.7961, 1.0),
50        }
51    }
52}
53
54impl From<Color> for SurfaceStyle {
55    fn from(color: Color) -> Self {
56        SurfaceStyle::Filled { color }
57    }
58}
59
60#[derive(Builder, Clone)]
61#[builder(pattern = "owned")]
62pub struct SurfaceArgs {
63    /// Defines the visual style of the surface (fill, outline, or both).
64    #[builder(default)]
65    pub style: SurfaceStyle,
66
67    /// Optional style to apply when the cursor is hovering over the surface.
68    /// This is only active when `on_click` is also provided.
69    #[builder(default)]
70    pub hover_style: Option<SurfaceStyle>,
71
72    /// Geometric outline of the surface (rounded rectangle / ellipse / capsule variants).
73    #[builder(default)]
74    pub shape: Shape,
75
76    /// Optional shadow/elevation style. When present it is passed through to the shape pipeline.
77    #[builder(default, setter(strip_option))]
78    pub shadow: Option<ShadowProps>,
79
80    /// Internal padding applied symmetrically (left/right & top/bottom). Child content is
81    /// positioned at (padding, padding). Also influences measured minimum size.
82    #[builder(default = "Dp(0.0)")]
83    pub padding: Dp,
84
85    /// Explicit width constraint (Fixed / Wrap / Fill). Defaults to `Wrap`.
86    #[builder(default = "DimensionValue::WRAP", setter(into))]
87    pub width: DimensionValue,
88
89    /// Explicit height constraint (Fixed / Wrap / Fill). Defaults to `Wrap`.
90    #[builder(default = "DimensionValue::WRAP", setter(into))]
91    pub height: DimensionValue,
92
93    /// Optional click handler. Presence of this value makes the surface interactive:
94    /// * Cursor changes to pointer when hovered
95    /// * Press / release events are captured
96    /// * Ripple animation starts on press if a `RippleState` is provided
97    #[builder(default, setter(strip_option))]
98    pub on_click: Option<Arc<dyn Fn() + Send + Sync>>,
99
100    /// Color of the ripple effect (if interactive & ripple state provided).
101    #[builder(default = "Color::from_rgb(1.0, 1.0, 1.0)")]
102    pub ripple_color: Color,
103
104    /// If true, all input events inside the surface bounds are blocked (stop propagation),
105    /// after (optionally) handling its own click logic.
106    #[builder(default = "false")]
107    pub block_input: bool,
108}
109
110impl Default for SurfaceArgs {
111    fn default() -> Self {
112        SurfaceArgsBuilder::default().build().unwrap()
113    }
114}
115
116fn build_ripple_props(args: &SurfaceArgs, ripple_state: Option<&Arc<RippleState>>) -> RippleProps {
117    if let Some(state) = ripple_state
118        && let Some((progress, click_pos)) = state.get_animation_progress()
119    {
120        let radius = progress;
121        let alpha = (1.0 - progress) * 0.3;
122        return RippleProps {
123            center: click_pos,
124            radius,
125            alpha,
126            color: args.ripple_color,
127        };
128    }
129    RippleProps::default()
130}
131
132fn build_rounded_rectangle_command(
133    args: &SurfaceArgs,
134    style: &SurfaceStyle,
135    ripple_props: RippleProps,
136    corner_radii: [f32; 4],
137    g2_k_value: f32,
138    interactive: bool,
139) -> ShapeCommand {
140    match style {
141        SurfaceStyle::Filled { color } => {
142            if interactive {
143                ShapeCommand::RippleRect {
144                    color: *color,
145                    corner_radii,
146                    g2_k_value,
147                    shadow: args.shadow,
148                    ripple: ripple_props,
149                }
150            } else {
151                ShapeCommand::Rect {
152                    color: *color,
153                    corner_radii,
154                    g2_k_value,
155                    shadow: args.shadow,
156                }
157            }
158        }
159        SurfaceStyle::Outlined { color, width } => {
160            if interactive {
161                ShapeCommand::RippleOutlinedRect {
162                    color: *color,
163                    corner_radii,
164                    g2_k_value,
165                    shadow: args.shadow,
166                    border_width: width.to_pixels_f32(),
167                    ripple: ripple_props,
168                }
169            } else {
170                ShapeCommand::OutlinedRect {
171                    color: *color,
172                    corner_radii,
173                    g2_k_value,
174                    shadow: args.shadow,
175                    border_width: width.to_pixels_f32(),
176                }
177            }
178        }
179        SurfaceStyle::FilledOutlined {
180            fill_color,
181            border_color,
182            border_width,
183        } => {
184            if interactive {
185                ShapeCommand::RippleFilledOutlinedRect {
186                    color: *fill_color,
187                    border_color: *border_color,
188                    corner_radii,
189                    g2_k_value,
190                    shadow: args.shadow,
191                    border_width: border_width.to_pixels_f32(),
192                    ripple: ripple_props,
193                }
194            } else {
195                ShapeCommand::FilledOutlinedRect {
196                    color: *fill_color,
197                    border_color: *border_color,
198                    corner_radii,
199                    g2_k_value,
200                    shadow: args.shadow,
201                    border_width: border_width.to_pixels_f32(),
202                }
203            }
204        }
205    }
206}
207
208fn build_ellipse_command(
209    args: &SurfaceArgs,
210    style: &SurfaceStyle,
211    ripple_props: RippleProps,
212    interactive: bool,
213) -> ShapeCommand {
214    let corner_marker = [-1.0, -1.0, -1.0, -1.0];
215    match style {
216        SurfaceStyle::Filled { color } => {
217            if interactive {
218                ShapeCommand::RippleRect {
219                    color: *color,
220                    corner_radii: corner_marker,
221                    g2_k_value: 0.0,
222                    shadow: args.shadow,
223                    ripple: ripple_props,
224                }
225            } else {
226                ShapeCommand::Ellipse {
227                    color: *color,
228                    shadow: args.shadow,
229                }
230            }
231        }
232        SurfaceStyle::Outlined { color, width } => {
233            if interactive {
234                ShapeCommand::RippleOutlinedRect {
235                    color: *color,
236                    corner_radii: corner_marker,
237                    g2_k_value: 0.0,
238                    shadow: args.shadow,
239                    border_width: width.to_pixels_f32(),
240                    ripple: ripple_props,
241                }
242            } else {
243                ShapeCommand::OutlinedEllipse {
244                    color: *color,
245                    shadow: args.shadow,
246                    border_width: width.to_pixels_f32(),
247                }
248            }
249        }
250        SurfaceStyle::FilledOutlined {
251            fill_color,
252            border_color,
253            border_width,
254        } => {
255            // NOTE: No ripple variant for FilledOutlinedEllipse yet.
256            ShapeCommand::FilledOutlinedEllipse {
257                color: *fill_color,
258                border_color: *border_color,
259                shadow: args.shadow,
260                border_width: border_width.to_pixels_f32(),
261            }
262        }
263    }
264}
265
266fn build_shape_command(
267    args: &SurfaceArgs,
268    style: &SurfaceStyle,
269    ripple_props: RippleProps,
270    size: PxSize,
271) -> ShapeCommand {
272    let interactive = args.on_click.is_some();
273
274    match args.shape {
275        Shape::RoundedRectangle {
276            top_left,
277            top_right,
278            bottom_right,
279            bottom_left,
280            g2_k_value,
281        } => {
282            let corner_radii = [
283                top_left.to_pixels_f32(),
284                top_right.to_pixels_f32(),
285                bottom_right.to_pixels_f32(),
286                bottom_left.to_pixels_f32(),
287            ];
288            build_rounded_rectangle_command(
289                args,
290                style,
291                ripple_props,
292                corner_radii,
293                g2_k_value,
294                interactive,
295            )
296        }
297        Shape::Ellipse => build_ellipse_command(args, style, ripple_props, interactive),
298        Shape::HorizontalCapsule => {
299            let radius = size.height.to_f32() / 2.0;
300            let corner_radii = [radius, radius, radius, radius];
301            build_rounded_rectangle_command(
302                args,
303                style,
304                ripple_props,
305                corner_radii,
306                2.0, // Use G1 curve for perfect circle
307                interactive,
308            )
309        }
310        Shape::VerticalCapsule => {
311            let radius = size.width.to_f32() / 2.0;
312            let corner_radii = [radius, radius, radius, radius];
313            build_rounded_rectangle_command(
314                args,
315                style,
316                ripple_props,
317                corner_radii,
318                2.0, // Use G1 curve for perfect circle
319                interactive,
320            )
321        }
322    }
323}
324
325fn make_surface_drawable(
326    args: &SurfaceArgs,
327    style: &SurfaceStyle,
328    ripple_state: Option<&Arc<RippleState>>,
329    size: PxSize,
330) -> ShapeCommand {
331    let ripple_props = build_ripple_props(args, ripple_state);
332    build_shape_command(args, style, ripple_props, size)
333}
334
335fn compute_surface_size(
336    effective_surface_constraint: Constraint,
337    child_measurement: ComputedData,
338    padding_px: Px,
339) -> (Px, Px) {
340    let min_width = child_measurement.width + padding_px * 2;
341    let min_height = child_measurement.height + padding_px * 2;
342
343    fn clamp_wrap(min: Option<Px>, max: Option<Px>, min_measure: Px) -> Px {
344        min.unwrap_or(Px(0))
345            .max(min_measure)
346            .min(max.unwrap_or(Px::MAX))
347    }
348
349    fn fill_value(min: Option<Px>, max: Option<Px>, min_measure: Px) -> Px {
350        max.expect("Seems that you are trying to fill an infinite dimension, which is not allowed")
351            .max(min_measure)
352            .max(min.unwrap_or(Px(0)))
353    }
354
355    let width = match effective_surface_constraint.width {
356        DimensionValue::Fixed(value) => value,
357        DimensionValue::Wrap { min, max } => clamp_wrap(min, max, min_width),
358        DimensionValue::Fill { min, max } => fill_value(min, max, min_width),
359    };
360
361    let height = match effective_surface_constraint.height {
362        DimensionValue::Fixed(value) => value,
363        DimensionValue::Wrap { min, max } => clamp_wrap(min, max, min_height),
364        DimensionValue::Fill { min, max } => fill_value(min, max, min_height),
365    };
366
367    (width, height)
368}
369
370/// Renders a styled rectangular (or elliptic / capsule) container and optionally
371/// provides interactive click + ripple feedback.
372///
373/// # Behavior
374/// * Child closure is executed first so that nested components are registered.
375/// * Layout (`measure`) phase:
376///   - Measures (optional) single child (if present) with padding removed from constraints
377///   - Computes final size using `width` / `height` (Wrap / Fill / Fixed) merging parent constraints
378///   - Pushes a shape draw command sized to computed width/height
379/// * Interaction (`input_handler`) phase (only when `on_click` is `Some`):
380///   - Tracks cursor containment
381///   - Sets hover state on provided `RippleState`
382///   - Starts ripple animation on mouse press
383///   - Invokes `on_click` on mouse release inside bounds
384///   - Optionally blocks further event propagation if `block_input` is true
385/// * Non‑interactive variant only blocks events if `block_input` and cursor inside.
386///
387/// # Ripple
388/// Ripple requires a `RippleState` (pass in `Some(Arc<RippleState>)`). Without it, the surface
389/// still detects clicks but no animation is shown.
390///
391/// # Sizing
392/// Effective minimum size = child size + `padding * 2` in each axis (if child exists).
393///
394/// # Example
395///
396/// ```
397/// use std::sync::Arc;
398/// use tessera_ui::{Dp, tessera, Color};
399/// use tessera_ui_basic_components::{
400///     surface::{surface, SurfaceArgsBuilder},
401///     ripple_state::RippleState,
402/// };
403///
404/// #[tessera]
405/// fn example_box() {
406///     let ripple = Arc::new(RippleState::new());
407///     surface(
408///         SurfaceArgsBuilder::default()
409///             .padding(Dp(8.0))
410///             .on_click(Arc::new(|| println!("Surface clicked")))
411///             .build()
412///             .unwrap(),
413///         Some(ripple),
414///         || {
415///             // child content here
416///         },
417///     );
418/// }
419/// ```
420#[tessera]
421pub fn surface(args: SurfaceArgs, ripple_state: Option<Arc<RippleState>>, child: impl FnOnce()) {
422    (child)();
423    let ripple_state_for_measure = ripple_state.clone();
424    let args_measure_clone = args.clone();
425    let args_for_handler = args.clone();
426
427    measure(Box::new(move |input| {
428        let surface_intrinsic_width = args_measure_clone.width;
429        let surface_intrinsic_height = args_measure_clone.height;
430        let surface_intrinsic_constraint =
431            Constraint::new(surface_intrinsic_width, surface_intrinsic_height);
432        let effective_surface_constraint =
433            surface_intrinsic_constraint.merge(input.parent_constraint);
434        let padding_px: Px = args_measure_clone.padding.into();
435        let child_constraint = Constraint::new(
436            remove_padding_from_dimension(effective_surface_constraint.width, padding_px),
437            remove_padding_from_dimension(effective_surface_constraint.height, padding_px),
438        );
439
440        let child_measurement = if !input.children_ids.is_empty() {
441            let child_measurements = input.measure_children(
442                input
443                    .children_ids
444                    .iter()
445                    .copied()
446                    .map(|node_id| (node_id, child_constraint))
447                    .collect(),
448            )?;
449            input.place_child(
450                input.children_ids[0],
451                PxPosition {
452                    x: args.padding.into(),
453                    y: args.padding.into(),
454                },
455            );
456            let mut max_width = Px::ZERO;
457            let mut max_height = Px::ZERO;
458            for measurement in child_measurements.values() {
459                max_width = max_width.max(measurement.width);
460                max_height = max_height.max(measurement.height);
461            }
462            ComputedData {
463                width: max_width,
464                height: max_height,
465            }
466        } else {
467            ComputedData {
468                width: Px(0),
469                height: Px(0),
470            }
471        };
472
473        let is_hovered = ripple_state_for_measure
474            .as_ref()
475            .map(|state| state.is_hovered())
476            .unwrap_or(false);
477
478        let effective_style = if is_hovered && args_measure_clone.hover_style.is_some() {
479            args_measure_clone.hover_style.as_ref().unwrap()
480        } else {
481            &args_measure_clone.style
482        };
483
484        let padding_px: Px = args_measure_clone.padding.into();
485        let (width, height) =
486            compute_surface_size(effective_surface_constraint, child_measurement, padding_px);
487
488        let drawable = make_surface_drawable(
489            &args_measure_clone,
490            effective_style,
491            ripple_state_for_measure.as_ref(),
492            PxSize::new(width, height),
493        );
494
495        input.metadata_mut().push_draw_command(drawable);
496
497        Ok(ComputedData { width, height })
498    }));
499
500    if args.on_click.is_some() {
501        let args_for_handler = args.clone();
502        let state_for_handler = ripple_state;
503        input_handler(Box::new(move |mut input| {
504            let size = input.computed_data;
505            let cursor_pos_option = input.cursor_position_rel;
506            let is_cursor_in_surface = cursor_pos_option
507                .map(|pos| is_position_in_component(size, pos))
508                .unwrap_or(false);
509
510            if let Some(ref state) = state_for_handler {
511                state.set_hovered(is_cursor_in_surface);
512            }
513
514            if is_cursor_in_surface && args_for_handler.on_click.is_some() {
515                input.requests.cursor_icon = CursorIcon::Pointer;
516            }
517
518            if is_cursor_in_surface {
519                let press_events: Vec<_> = input
520                    .cursor_events
521                    .iter()
522                    .filter(|event| {
523                        matches!(
524                            event.content,
525                            CursorEventContent::Pressed(PressKeyEventType::Left)
526                        )
527                    })
528                    .collect();
529
530                let release_events: Vec<_> = input
531                    .cursor_events
532                    .iter()
533                    .filter(|event| {
534                        matches!(
535                            event.content,
536                            CursorEventContent::Released(PressKeyEventType::Left)
537                        )
538                    })
539                    .collect();
540
541                if !press_events.is_empty()
542                    && let (Some(cursor_pos), Some(state)) =
543                        (cursor_pos_option, state_for_handler.as_ref())
544                {
545                    let normalized_x = (cursor_pos.x.to_f32() / size.width.to_f32()) - 0.5;
546                    let normalized_y = (cursor_pos.y.to_f32() / size.height.to_f32()) - 0.5;
547
548                    state.start_animation([normalized_x, normalized_y]);
549                }
550
551                if !release_events.is_empty()
552                    && let Some(ref on_click) = args_for_handler.on_click
553                {
554                    on_click();
555                }
556
557                if args_for_handler.block_input {
558                    input.block_all();
559                }
560            }
561        }));
562    } else {
563        input_handler(Box::new(move |mut input| {
564            let size = input.computed_data;
565            let cursor_pos_option = input.cursor_position_rel;
566            let is_cursor_in_surface = cursor_pos_option
567                .map(|pos| is_position_in_component(size, pos))
568                .unwrap_or(false);
569            if args_for_handler.block_input && is_cursor_in_surface {
570                input.block_all();
571            }
572        }));
573    }
574}