tessera_ui_basic_components/
fluid_glass.rs

1//! Fluid glass effect module for Tessera UI Basic Components.
2//!
3//! This module provides the core implementation for the "fluid glass" (frosted/distorted glass) visual effect,
4//! including parameter structures, builder patterns, and the main `fluid_glass` component.
5//! It enables highly customizable backgrounds with blur, tint, chromatic dispersion, noise, and ripple effects,
6//! suitable for creating modern, layered user interfaces with enhanced depth and focus.
7//! Typical use cases include backgrounds for buttons, sliders, switches, and other interactive UI elements
8//! where a dynamic, visually appealing glass-like surface is desired.
9
10use std::sync::Arc;
11
12use derive_builder::Builder;
13use tessera_ui::{
14    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, PressKeyEventType, Px,
15    PxPosition, renderer::DrawCommand, winit::window::CursorIcon,
16};
17use tessera_ui_macros::tessera;
18
19use crate::{
20    padding_utils::remove_padding_from_dimension,
21    pipelines::{blur::command::BlurCommand, contrast::ContrastCommand, mean::MeanCommand},
22    pos_misc::is_position_in_component,
23    ripple_state::RippleState,
24    shape_def::Shape,
25};
26
27#[derive(Clone, Copy, Debug, Default)]
28pub struct GlassBorder {
29    pub width: Px,
30}
31
32impl GlassBorder {
33    pub fn new(width: Px) -> Self {
34        Self { width }
35    }
36}
37
38/// Arguments for the `fluid_glass` component, providing extensive control over its appearance.
39///
40/// This struct uses the builder pattern for easy construction.
41#[derive(Builder, Clone)]
42#[builder(build_fn(validate = "Self::validate"), pattern = "owned", setter(into))]
43pub struct FluidGlassArgs {
44    /// The tint color of the glass.
45    /// The alpha channel uniquely and directly controls the tint strength.
46    /// `A=0.0` means no tint (100% background visibility).
47    /// `A=1.0` means full tint (100% color visibility).
48    #[builder(default = "Color::TRANSPARENT")]
49    pub tint_color: Color,
50    /// The shape of the component, an enum that can be `RoundedRectangle` or `Ellipse`.
51    #[builder(default = "Shape::RoundedRectangle { corner_radius: 25.0, g2_k_value: 3.0 }")]
52    pub shape: Shape,
53    /// The radius for the background blur effect. A value of `0.0` disables the blur.
54    #[builder(default = "0.0")]
55    pub blur_radius: f32,
56    /// The height of the chromatic dispersion effect.
57    #[builder(default = "25.0")]
58    pub dispersion_height: f32,
59    /// Multiplier for the chromatic aberration, enhancing the color separation effect.
60    #[builder(default = "1.0")]
61    pub chroma_multiplier: f32,
62    /// The height of the refraction effect, simulating light bending through the glass.
63    #[builder(default = "24.0")]
64    pub refraction_height: f32,
65    /// The amount of refraction to apply.
66    #[builder(default = "32.0")]
67    pub refraction_amount: f32,
68    /// Controls the shape and eccentricity of the highlight.
69    #[builder(default = "0.2")]
70    pub eccentric_factor: f32,
71    /// The amount of noise to apply over the surface, adding texture.
72    #[builder(default = "0.02")]
73    pub noise_amount: f32,
74    /// The scale of the noise pattern.
75    #[builder(default = "1.0")]
76    pub noise_scale: f32,
77    /// A time value, typically used to animate the noise or other effects.
78    #[builder(default = "0.0")]
79    pub time: f32,
80    /// The contrast adjustment factor.
81    #[builder(default, setter(strip_option))]
82    pub contrast: Option<f32>,
83    /// The optional width of the component, defined as a `DimensionValue`.
84    #[builder(default, setter(strip_option))]
85    pub width: Option<DimensionValue>,
86    /// The optional height of the component, defined as a `DimensionValue`.
87    #[builder(default, setter(strip_option))]
88    pub height: Option<DimensionValue>,
89
90    #[builder(default = "Dp(0.0)")]
91    pub padding: Dp,
92
93    // Ripple effect properties
94    #[builder(default, setter(strip_option))]
95    pub ripple_center: Option<[f32; 2]>,
96    #[builder(default, setter(strip_option))]
97    pub ripple_radius: Option<f32>,
98    #[builder(default, setter(strip_option))]
99    pub ripple_alpha: Option<f32>,
100    #[builder(default, setter(strip_option))]
101    pub ripple_strength: Option<f32>,
102
103    #[builder(default, setter(strip_option, into = false))]
104    pub on_click: Option<Arc<dyn Fn() + Send + Sync>>,
105
106    #[builder(default = "Some(GlassBorder { width: Px(1) })")]
107    pub border: Option<GlassBorder>,
108
109    /// Whether to block input events on the glass surface.
110    /// When `true`, the surface will consume all input events, preventing interaction with underlying components.
111    #[builder(default = "false")]
112    pub block_input: bool,
113}
114
115impl FluidGlassArgsBuilder {
116    fn validate(&self) -> Result<(), String> {
117        Ok(())
118    }
119}
120
121// Manual implementation of Default because derive_builder's default conflicts with our specific defaults
122impl Default for FluidGlassArgs {
123    fn default() -> Self {
124        FluidGlassArgsBuilder::default().build().unwrap()
125    }
126}
127
128#[derive(Clone)]
129pub struct FluidGlassCommand {
130    pub args: FluidGlassArgs,
131}
132
133impl DrawCommand for FluidGlassCommand {
134    fn barrier(&self) -> Option<tessera_ui::BarrierRequirement> {
135        // Fluid glass aquires the scene texture, so it needs to sample the background
136        Some(tessera_ui::BarrierRequirement::SampleBackground)
137    }
138}
139
140#[tessera]
141/// Creates a fluid glass effect component, which serves as a dynamic and visually appealing background.
142///
143/// The `fluid_glass` component simulates the look of frosted or distorted glass with a fluid,
144/// animated texture. It can be used to create modern, layered user interfaces where the background
145/// content is blurred and stylized, enhancing depth and focus. The effect is highly customizable
146/// through `FluidGlassArgs`.
147///
148/// # Example
149///
150/// ```
151/// use tessera_ui_basic_components::{
152///     fluid_glass::{fluid_glass, FluidGlassArgs},
153///     text::text,
154/// };
155///
156/// fluid_glass(FluidGlassArgs::default(), None, || {
157///     text("Content on glass".to_string());
158/// });
159/// ```
160///
161/// # Arguments
162///
163/// * `args` - A `FluidGlassArgs` struct that specifies the appearance and behavior of the glass
164///   effect. This includes properties like tint color, shape, blur radius, and noise level.
165///   The builder pattern is recommended for constructing the arguments.
166///
167/// * `ripple_state` - An optional `Arc<RippleState>` to enable and manage a ripple effect on user
168///   interaction, such as a click. When `None`, no ripple effect is applied.
169///
170/// * `child` - A closure that defines the child components to be rendered on top of the glass surface.
171///   These children will be contained within the bounds of the `fluid_glass` component.
172pub fn fluid_glass(
173    mut args: FluidGlassArgs,
174    ripple_state: Option<Arc<RippleState>>,
175    child: impl FnOnce(),
176) {
177    if let Some(ripple_state) = &ripple_state {
178        if let Some((progress, center)) = ripple_state.get_animation_progress() {
179            args.ripple_center = Some(center);
180            args.ripple_radius = Some(progress);
181            args.ripple_alpha = Some((1.0 - progress) * 0.3);
182            args.ripple_strength = Some(progress);
183        }
184    }
185    (child)();
186    let args_measure_clone = args.clone();
187    measure(Box::new(move |input| {
188        let glass_intrinsic_width = args_measure_clone.width.unwrap_or(DimensionValue::Wrap {
189            min: None,
190            max: None,
191        });
192        let glass_intrinsic_height = args_measure_clone.height.unwrap_or(DimensionValue::Wrap {
193            min: None,
194            max: None,
195        });
196        let glass_intrinsic_constraint =
197            Constraint::new(glass_intrinsic_width, glass_intrinsic_height);
198        let effective_glass_constraint = glass_intrinsic_constraint.merge(input.parent_constraint);
199
200        let child_constraint = Constraint::new(
201            remove_padding_from_dimension(
202                effective_glass_constraint.width,
203                args_measure_clone.padding.into(),
204            ),
205            remove_padding_from_dimension(
206                effective_glass_constraint.height,
207                args_measure_clone.padding.into(),
208            ),
209        );
210
211        let child_measurement = if !input.children_ids.is_empty() {
212            let child_measurement =
213                input.measure_child(input.children_ids[0], &child_constraint)?;
214            input.place_child(
215                input.children_ids[0],
216                PxPosition {
217                    x: args.padding.into(),
218                    y: args.padding.into(),
219                },
220            );
221            child_measurement
222        } else {
223            ComputedData {
224                width: Px(0),
225                height: Px(0),
226            }
227        };
228
229        if args.blur_radius > 0.0 {
230            let blur_command = BlurCommand {
231                radius: args.blur_radius,
232                direction: (1.0, 0.0), // Horizontal
233            };
234            let blur_command2 = BlurCommand {
235                radius: args.blur_radius,
236                direction: (0.0, 1.0), // Vertical
237            };
238            let mut metadata = input.metadata_mut();
239            metadata.push_compute_command(blur_command);
240            metadata.push_compute_command(blur_command2);
241        }
242
243        if let Some(contrast_value) = args.contrast {
244            let mean_command =
245                MeanCommand::new(input.gpu, &mut input.compute_resource_manager.write());
246            let contrast_command =
247                ContrastCommand::new(contrast_value, mean_command.result_buffer_ref());
248            let mut metadata = input.metadata_mut();
249            metadata.push_compute_command(mean_command);
250            metadata.push_compute_command(contrast_command);
251        }
252
253        let drawable = FluidGlassCommand {
254            args: args_measure_clone.clone(),
255        };
256
257        input.metadata_mut().push_draw_command(drawable);
258
259        let padding_px: Px = args_measure_clone.padding.into();
260        let min_width = child_measurement.width + padding_px * 2;
261        let min_height = child_measurement.height + padding_px * 2;
262        let width = match effective_glass_constraint.width {
263            DimensionValue::Fixed(value) => value,
264            DimensionValue::Wrap { min, max } => min
265                .unwrap_or(Px(0))
266                .max(min_width)
267                .min(max.unwrap_or(Px::MAX)),
268            DimensionValue::Fill { min, max } => max
269                .expect("Seems that you are trying to fill an infinite width, which is not allowed")
270                .max(min_width)
271                .max(min.unwrap_or(Px(0))),
272        };
273        let height = match effective_glass_constraint.height {
274            DimensionValue::Fixed(value) => value,
275            DimensionValue::Wrap { min, max } => min
276                .unwrap_or(Px(0))
277                .max(min_height)
278                .min(max.unwrap_or(Px::MAX)),
279            DimensionValue::Fill { min, max } => max
280                .expect(
281                    "Seems that you are trying to fill an infinite height, which is not allowed",
282                )
283                .max(min_height)
284                .max(min.unwrap_or(Px(0))),
285        };
286        Ok(ComputedData { width, height })
287    }));
288
289    if let Some(on_click) = args.on_click {
290        let ripple_state = ripple_state.clone();
291        state_handler(Box::new(move |mut input| {
292            let size = input.computed_data;
293            let cursor_pos_option = input.cursor_position_rel;
294            let is_cursor_in = cursor_pos_option
295                .map(|pos| is_position_in_component(size, pos))
296                .unwrap_or(false);
297
298            if is_cursor_in {
299                input.requests.cursor_icon = CursorIcon::Pointer;
300            }
301
302            if is_cursor_in {
303                if let Some(_event) = input.cursor_events.iter().find(|e| {
304                    matches!(
305                        e.content,
306                        CursorEventContent::Pressed(PressKeyEventType::Left)
307                    )
308                }) {
309                    if let Some(ripple_state) = &ripple_state {
310                        if let Some(pos) = input.cursor_position_rel {
311                            let size = input.computed_data;
312                            let normalized_pos = [
313                                pos.x.to_f32() / size.width.to_f32(),
314                                pos.y.to_f32() / size.height.to_f32(),
315                            ];
316                            ripple_state.start_animation(normalized_pos);
317                        }
318                    }
319                    on_click();
320                }
321
322                if args.block_input {
323                    // Consume all input events to prevent interaction with underlying components
324                    input.block_all();
325                }
326            }
327        }));
328    } else if args.block_input {
329        state_handler(Box::new(move |mut input| {
330            let size = input.computed_data;
331            let cursor_pos_option = input.cursor_position_rel;
332            let is_cursor_in = cursor_pos_option
333                .map(|pos| is_position_in_component(size, pos))
334                .unwrap_or(false);
335
336            if is_cursor_in {
337                // Consume all input events to prevent interaction with underlying components
338                input.block_all();
339            }
340        }));
341    }
342}