tessera_ui_basic_components/
fluid_glass.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, renderer::DrawCommand, winit::window::CursorIcon,
7};
8use tessera_ui_macros::tessera;
9
10use crate::{
11    padding_utils::remove_padding_from_dimension,
12    pipelines::{blur::command::BlurCommand, contrast::ContrastCommand, mean::MeanCommand},
13    pos_misc::is_position_in_component,
14    ripple_state::RippleState,
15    shape_def::Shape,
16};
17
18#[derive(Clone, Copy, Debug, Default)]
19pub struct GlassBorder {
20    pub width: Dp,
21    pub color: Color,
22}
23
24impl GlassBorder {
25    pub fn new(width: Dp, color: Color) -> Self {
26        Self { width, color }
27    }
28}
29
30/// Arguments for the `fluid_glass` component, providing extensive control over its appearance.
31///
32/// This struct uses the builder pattern for easy construction.
33#[derive(Builder, Clone)]
34#[builder(build_fn(validate = "Self::validate"), pattern = "owned", setter(into))]
35pub struct FluidGlassArgs {
36    /// The tint color of the glass.
37    /// The alpha channel uniquely and directly controls the tint strength.
38    /// `A=0.0` means no tint (100% background visibility).
39    /// `A=1.0` means full tint (100% color visibility).
40    #[builder(default = "Color::new(0.5, 0.5, 0.5, 0.1)")]
41    pub tint_color: Color,
42    /// The shape of the component, an enum that can be `RoundedRectangle` or `Ellipse`.
43    #[builder(default = "Shape::RoundedRectangle { corner_radius: 25.0, g2_k_value: 3.0 }")]
44    pub shape: Shape,
45    /// The radius for the background blur effect. A value of `0.0` disables the blur.
46    #[builder(default = "0.0")]
47    pub blur_radius: f32,
48    /// The height of the chromatic dispersion effect.
49    #[builder(default = "25.0")]
50    pub dispersion_height: f32,
51    /// Multiplier for the chromatic aberration, enhancing the color separation effect.
52    #[builder(default = "1.2")]
53    pub chroma_multiplier: f32,
54    /// The height of the refraction effect, simulating light bending through the glass.
55    #[builder(default = "24.0")]
56    pub refraction_height: f32,
57    /// The amount of refraction to apply.
58    #[builder(default = "32.0")]
59    pub refraction_amount: f32,
60    /// Controls the shape and eccentricity of the highlight.
61    #[builder(default = "0.2")]
62    pub eccentric_factor: f32,
63    /// The amount of noise to apply over the surface, adding texture.
64    #[builder(default = "0.02")]
65    pub noise_amount: f32,
66    /// The scale of the noise pattern.
67    #[builder(default = "1.0")]
68    pub noise_scale: f32,
69    /// A time value, typically used to animate the noise or other effects.
70    #[builder(default = "0.0")]
71    pub time: f32,
72    /// The contrast adjustment factor.
73    #[builder(default, setter(strip_option))]
74    pub contrast: Option<f32>,
75    /// The optional width of the component, defined as a `DimensionValue`.
76    #[builder(default, setter(strip_option))]
77    pub width: Option<DimensionValue>,
78    /// The optional height of the component, defined as a `DimensionValue`.
79    #[builder(default, setter(strip_option))]
80    pub height: Option<DimensionValue>,
81
82    #[builder(default = "Dp(0.0)")]
83    pub padding: Dp,
84
85    // Ripple effect properties
86    #[builder(default, setter(strip_option))]
87    pub ripple_center: Option<[f32; 2]>,
88    #[builder(default, setter(strip_option))]
89    pub ripple_radius: Option<f32>,
90    #[builder(default, setter(strip_option))]
91    pub ripple_alpha: Option<f32>,
92    #[builder(default, setter(strip_option))]
93    pub ripple_strength: Option<f32>,
94
95    #[builder(default, setter(strip_option, into = false))]
96    pub on_click: Option<Arc<dyn Fn() + Send + Sync>>,
97
98    #[builder(default, setter(strip_option))]
99    pub border: Option<GlassBorder>,
100}
101
102impl FluidGlassArgsBuilder {
103    fn validate(&self) -> Result<(), String> {
104        Ok(())
105    }
106}
107
108// Manual implementation of Default because derive_builder's default conflicts with our specific defaults
109impl Default for FluidGlassArgs {
110    fn default() -> Self {
111        FluidGlassArgsBuilder::default().build().unwrap()
112    }
113}
114
115#[derive(Clone)]
116pub struct FluidGlassCommand {
117    pub args: FluidGlassArgs,
118}
119
120impl DrawCommand for FluidGlassCommand {
121    fn barrier(&self) -> Option<tessera_ui::BarrierRequirement> {
122        // Fluid glass aquires the scene texture, so it needs to sample the background
123        Some(tessera_ui::BarrierRequirement::SampleBackground)
124    }
125}
126
127#[tessera]
128pub fn fluid_glass(
129    args: FluidGlassArgs,
130    ripple_state: Option<Arc<RippleState>>,
131    child: impl FnOnce(),
132) {
133    (child)();
134    let args_measure_clone = args.clone();
135    measure(Box::new(move |input| {
136        let glass_intrinsic_width = args_measure_clone.width.unwrap_or(DimensionValue::Wrap {
137            min: None,
138            max: None,
139        });
140        let glass_intrinsic_height = args_measure_clone.height.unwrap_or(DimensionValue::Wrap {
141            min: None,
142            max: None,
143        });
144        let glass_intrinsic_constraint =
145            Constraint::new(glass_intrinsic_width, glass_intrinsic_height);
146        let effective_glass_constraint = glass_intrinsic_constraint.merge(input.parent_constraint);
147
148        let child_constraint = Constraint::new(
149            remove_padding_from_dimension(
150                effective_glass_constraint.width,
151                args_measure_clone.padding.into(),
152            ),
153            remove_padding_from_dimension(
154                effective_glass_constraint.height,
155                args_measure_clone.padding.into(),
156            ),
157        );
158
159        let child_measurement = if !input.children_ids.is_empty() {
160            let child_measurement = measure_node(
161                input.children_ids[0],
162                &child_constraint,
163                input.tree,
164                input.metadatas,
165                input.compute_resource_manager.clone(),
166                input.gpu,
167            )?;
168            place_node(
169                input.children_ids[0],
170                PxPosition {
171                    x: args.padding.into(),
172                    y: args.padding.into(),
173                },
174                input.metadatas,
175            );
176            child_measurement
177        } else {
178            ComputedData {
179                width: Px(0),
180                height: Px(0),
181            }
182        };
183
184        if args.blur_radius > 0.0 {
185            let blur_command = BlurCommand {
186                radius: args.blur_radius,
187                direction: (1.0, 0.0), // Horizontal
188            };
189            let blur_command2 = BlurCommand {
190                radius: args.blur_radius,
191                direction: (0.0, 1.0), // Vertical
192            };
193            if let Some(mut metadata) = input.metadatas.get_mut(&input.current_node_id) {
194                metadata.push_compute_command(blur_command);
195                metadata.push_compute_command(blur_command2);
196            }
197        }
198
199        if let Some(contrast_value) = args.contrast {
200            let mean_command =
201                MeanCommand::new(input.gpu, &mut input.compute_resource_manager.write());
202            let contrast_command =
203                ContrastCommand::new(contrast_value, mean_command.result_buffer_ref());
204            if let Some(mut metadata) = input.metadatas.get_mut(&input.current_node_id) {
205                metadata.push_compute_command(mean_command);
206                metadata.push_compute_command(contrast_command);
207            }
208        }
209
210        let drawable = FluidGlassCommand {
211            args: args_measure_clone.clone(),
212        };
213
214        if let Some(mut metadata) = input.metadatas.get_mut(&input.current_node_id) {
215            metadata.push_draw_command(drawable);
216        }
217
218        let padding_px: Px = args_measure_clone.padding.into();
219        let min_width = child_measurement.width + padding_px * 2;
220        let min_height = child_measurement.height + padding_px * 2;
221        let width = match effective_glass_constraint.width {
222            DimensionValue::Fixed(value) => value,
223            DimensionValue::Wrap { min, max } => min
224                .unwrap_or(Px(0))
225                .max(min_width)
226                .min(max.unwrap_or(Px::MAX)),
227            DimensionValue::Fill { min, max } => max
228                .expect("Seems that you are trying to fill an infinite width, which is not allowed")
229                .max(min_height)
230                .max(min.unwrap_or(Px(0))),
231        };
232        let height = match effective_glass_constraint.height {
233            DimensionValue::Fixed(value) => value,
234            DimensionValue::Wrap { min, max } => min
235                .unwrap_or(Px(0))
236                .max(min_height)
237                .min(max.unwrap_or(Px::MAX)),
238            DimensionValue::Fill { min, max } => max
239                .expect(
240                    "Seems that you are trying to fill an infinite height, which is not allowed",
241                )
242                .max(min_height)
243                .max(min.unwrap_or(Px(0))),
244        };
245        Ok(ComputedData { width, height })
246    }));
247
248    if let Some(on_click) = args.on_click {
249        let ripple_state = ripple_state.clone();
250        state_handler(Box::new(move |input| {
251            let size = input.computed_data;
252            let cursor_pos_option = input.cursor_position;
253            let is_cursor_in = cursor_pos_option
254                .map(|pos| is_position_in_component(size, pos))
255                .unwrap_or(false);
256
257            if is_cursor_in {
258                input.requests.cursor_icon = CursorIcon::Pointer;
259            }
260
261            if is_cursor_in {
262                if let Some(_event) = input.cursor_events.iter().find(|e| {
263                    matches!(
264                        e.content,
265                        CursorEventContent::Pressed(PressKeyEventType::Left)
266                    )
267                }) {
268                    if let Some(ripple_state) = &ripple_state {
269                        if let Some(pos) = input.cursor_position {
270                            let size = input.computed_data;
271                            let normalized_pos = [
272                                pos.x.to_f32() / size.width.to_f32(),
273                                pos.y.to_f32() / size.height.to_f32(),
274                            ];
275                            ripple_state.start_animation(normalized_pos);
276                        }
277                    }
278                    on_click();
279                }
280            }
281        }));
282    }
283}