tessera_ui_basic_components/
fluid_glass.rs

1//! A component for creating a frosted/distorted glass visual effect.
2//!
3//! ## Usage
4//!
5//! Use as a background for buttons, panels, or other UI elements.
6use std::sync::Arc;
7
8use derive_builder::Builder;
9use tessera_ui::{
10    BarrierRequirement, Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp,
11    GestureState, PressKeyEventType, Px, PxPosition,
12    accesskit::{Action, Role},
13    renderer::DrawCommand,
14    tessera,
15    winit::window::CursorIcon,
16};
17
18use crate::{
19    padding_utils::remove_padding_from_dimension,
20    pipelines::{
21        blur::command::DualBlurCommand, contrast::ContrastCommand, mean::command::MeanCommand,
22    },
23    pos_misc::is_position_in_component,
24    ripple_state::RippleState,
25    shape_def::{RoundedCorner, Shape},
26};
27
28/// Border properties applied to the glass surface.
29///
30/// # Example
31///
32/// ```
33/// use tessera_ui::Px;
34/// use tessera_ui_basic_components::fluid_glass::GlassBorder;
35///
36/// let border = GlassBorder::new(Px(2)); // Creates a border with 2 physical pixels width
37/// ```
38#[derive(Clone, Copy, Debug, Default, PartialEq)]
39pub struct GlassBorder {
40    /// Border width in physical pixels.
41    pub width: Px,
42}
43
44impl GlassBorder {
45    /// Creates a new border with the given width.
46    pub fn new(width: Px) -> Self {
47        Self { width }
48    }
49}
50
51/// Arguments for the `fluid_glass` component, providing extensive control over its appearance.
52///
53/// This struct uses the builder pattern for easy construction.
54#[derive(Builder, Clone)]
55#[builder(build_fn(validate = "Self::validate"), pattern = "owned", setter(into))]
56pub struct FluidGlassArgs {
57    /// The tint color of the glass.
58    /// The alpha channel uniquely and directly controls the tint strength.
59    /// `A=0.0` means no tint (100% background visibility).
60    /// `A=1.0` means full tint (100% color visibility).
61    #[builder(default = "Color::TRANSPARENT")]
62    pub tint_color: Color,
63    /// The shape of the component, an enum that can be `RoundedRectangle` or `Ellipse`.
64    #[builder(default = "Shape::RoundedRectangle {
65            top_left: RoundedCorner::manual(Dp(25.0), 3.0),
66            top_right: RoundedCorner::manual(Dp(25.0), 3.0),
67            bottom_right: RoundedCorner::manual(Dp(25.0), 3.0),
68            bottom_left: RoundedCorner::manual(Dp(25.0), 3.0),
69        }")]
70    pub shape: Shape,
71    /// The radius for the background blur effect. A value of `0.0` disables the blur.
72    #[builder(default = "Dp(0.0)")]
73    pub blur_radius: Dp,
74    /// The height of the chromatic dispersion effect.
75    #[builder(default = "Dp(25.0)")]
76    pub dispersion_height: Dp,
77    /// Multiplier for the chromatic aberration, enhancing the color separation effect.
78    #[builder(default = "1.1")]
79    pub chroma_multiplier: f32,
80    /// The height of the refraction effect, simulating light bending through the glass.
81    #[builder(default = "Dp(24.0)")]
82    pub refraction_height: Dp,
83    /// The amount of refraction to apply.
84    #[builder(default = "32.0")]
85    pub refraction_amount: f32,
86    /// Controls the shape and eccentricity of the highlight.
87    #[builder(default = "0.2")]
88    pub eccentric_factor: f32,
89    /// The amount of noise to apply over the surface, adding texture.
90    #[builder(default = "0.0")]
91    pub noise_amount: f32,
92    /// The scale of the noise pattern.
93    #[builder(default = "1.0")]
94    pub noise_scale: f32,
95    /// A time value, typically used to animate the noise or other effects.
96    #[builder(default = "0.0")]
97    pub time: f32,
98    /// The contrast adjustment factor.
99    #[builder(default, setter(strip_option))]
100    pub contrast: Option<f32>,
101    /// The optional width of the component, defined as a `DimensionValue`.
102    #[builder(default = "DimensionValue::WRAP", setter(into))]
103    pub width: DimensionValue,
104    /// The optional height of the component, defined as a `DimensionValue`.
105    #[builder(default = "DimensionValue::WRAP", setter(into))]
106    pub height: DimensionValue,
107    /// Padding inside the glass component.
108    #[builder(default = "Dp(0.0)")]
109    pub padding: Dp,
110    /// Optional normalized center (x, y) for the ripple animation on click.
111    #[builder(default, setter(strip_option))]
112    pub ripple_center: Option<[f32; 2]>,
113    /// Optional ripple radius, expressed in normalized coordinates relative to the surface.
114    #[builder(default, setter(strip_option))]
115    pub ripple_radius: Option<f32>,
116    /// Optional ripple tint alpha (0.0 = transparent, 1.0 = opaque).
117    #[builder(default, setter(strip_option))]
118    pub ripple_alpha: Option<f32>,
119    /// Strength multiplier for the ripple distortion.
120    #[builder(default, setter(strip_option))]
121    pub ripple_strength: Option<f32>,
122
123    /// Optional click callback for interactive glass surfaces.
124    #[builder(default, setter(strip_option, into = false))]
125    pub on_click: Option<Arc<dyn Fn() + Send + Sync>>,
126
127    /// Optional border defining the outline thickness for the glass.
128    #[builder(default = "Some(GlassBorder { width: Dp(1.35).into() })")]
129    pub border: Option<GlassBorder>,
130
131    /// Whether to block input events on the glass surface.
132    /// When `true`, the surface will consume all input events, preventing interaction with underlying components.
133    #[builder(default = "false")]
134    pub block_input: bool,
135    /// Optional accessibility role override; defaults to `Role::Button` when interactive.
136    #[builder(default, setter(strip_option))]
137    pub accessibility_role: Option<Role>,
138    /// Optional label announced by assistive technologies.
139    #[builder(default, setter(strip_option, into))]
140    pub accessibility_label: Option<String>,
141    /// Optional description announced by assistive technologies.
142    #[builder(default, setter(strip_option, into))]
143    pub accessibility_description: Option<String>,
144    /// Whether the surface should be focusable even when not interactive.
145    #[builder(default)]
146    pub accessibility_focusable: bool,
147}
148
149impl PartialEq for FluidGlassArgs {
150    fn eq(&self, other: &Self) -> bool {
151        self.tint_color == other.tint_color
152            && self.shape == other.shape
153            && self.blur_radius == other.blur_radius
154            && self.dispersion_height == other.dispersion_height
155            && self.chroma_multiplier == other.chroma_multiplier
156            && self.refraction_height == other.refraction_height
157            && self.refraction_amount == other.refraction_amount
158            && self.eccentric_factor == other.eccentric_factor
159            && self.noise_amount == other.noise_amount
160            && self.noise_scale == other.noise_scale
161            && self.time == other.time
162            && self.contrast == other.contrast
163            && self.width == other.width
164            && self.height == other.height
165            && self.padding == other.padding
166            && self.ripple_center == other.ripple_center
167            && self.ripple_radius == other.ripple_radius
168            && self.ripple_alpha == other.ripple_alpha
169            && self.ripple_strength == other.ripple_strength
170            && self.border == other.border
171            && self.block_input == other.block_input
172    }
173}
174
175impl FluidGlassArgsBuilder {
176    fn validate(&self) -> Result<(), String> {
177        Ok(())
178    }
179}
180
181// Manual implementation of Default because derive_builder's default conflicts with our specific defaults
182impl Default for FluidGlassArgs {
183    fn default() -> Self {
184        FluidGlassArgsBuilder::default()
185            .build()
186            .expect("builder construction failed")
187    }
188}
189
190/// Draw command wrapping the arguments for the fluid glass surface.
191#[derive(Clone, PartialEq)]
192pub struct FluidGlassCommand {
193    /// Full configuration used by the draw pipeline.
194    pub args: FluidGlassArgs,
195}
196
197impl DrawCommand for FluidGlassCommand {
198    fn barrier(&self) -> Option<BarrierRequirement> {
199        Some(BarrierRequirement::uniform_padding_local(Px(10)))
200    }
201}
202
203// Helper: input handler logic extracted to reduce complexity of `fluid_glass`
204// These helpers operate on the injected InputHandlerInput type from the core crate.
205fn handle_click_state(
206    args: &FluidGlassArgs,
207    ripple_state: Option<RippleState>,
208    on_click: Arc<dyn Fn() + Send + Sync>,
209    input: &mut tessera_ui::InputHandlerInput,
210) {
211    let size = input.computed_data;
212    let cursor_pos_option = input.cursor_position_rel;
213    let is_cursor_in = cursor_pos_option
214        .map(|pos| is_position_in_component(size, pos))
215        .unwrap_or(false);
216
217    if is_cursor_in {
218        input.requests.cursor_icon = CursorIcon::Pointer;
219
220        if let Some(_event) = input.cursor_events.iter().find(|e| {
221            e.gesture_state == GestureState::TapCandidate
222                && matches!(
223                    e.content,
224                    CursorEventContent::Released(PressKeyEventType::Left)
225                )
226        }) {
227            if let Some(ripple_state) = &ripple_state
228                && let Some(pos) = input.cursor_position_rel
229            {
230                let size = input.computed_data;
231                let normalized_pos = [
232                    pos.x.to_f32() / size.width.to_f32(),
233                    pos.y.to_f32() / size.height.to_f32(),
234                ];
235                ripple_state.start_animation(normalized_pos);
236            }
237            on_click();
238        }
239
240        if args.block_input {
241            // Consume all input events to prevent interaction with underlying components
242            input.block_all();
243        }
244    }
245}
246
247fn handle_block_input(input: &mut tessera_ui::InputHandlerInput) {
248    let size = input.computed_data;
249    let cursor_pos_option = input.cursor_position_rel;
250    let is_cursor_in = cursor_pos_option
251        .map(|pos| is_position_in_component(size, pos))
252        .unwrap_or(false);
253
254    if is_cursor_in {
255        // Consume all input events to prevent interaction with underlying components
256        input.block_all();
257    }
258}
259
260fn apply_fluid_glass_accessibility(
261    input: &mut tessera_ui::InputHandlerInput<'_>,
262    args: &FluidGlassArgs,
263    on_click: &Option<Arc<dyn Fn() + Send + Sync>>,
264) {
265    let interactive = on_click.is_some();
266    let has_metadata = interactive
267        || args.accessibility_role.is_some()
268        || args.accessibility_label.is_some()
269        || args.accessibility_description.is_some()
270        || args.accessibility_focusable;
271
272    if !has_metadata {
273        return;
274    }
275
276    let mut builder = input.accessibility();
277
278    let role = args
279        .accessibility_role
280        .or_else(|| interactive.then_some(Role::Button));
281    if let Some(role) = role {
282        builder = builder.role(role);
283    }
284    if let Some(label) = args.accessibility_label.as_ref() {
285        builder = builder.label(label.clone());
286    }
287    if let Some(description) = args.accessibility_description.as_ref() {
288        builder = builder.description(description.clone());
289    }
290    if args.accessibility_focusable || interactive {
291        builder = builder.focusable();
292    }
293    if interactive {
294        builder = builder.action(Action::Click);
295    }
296
297    builder.commit();
298
299    if interactive && let Some(on_click) = on_click.clone() {
300        input.set_accessibility_action_handler(move |action| {
301            if action == Action::Click {
302                on_click();
303            }
304        });
305    }
306}
307
308/// # fluid_glass
309///
310/// Renders a highly customizable surface with blur, tint, and other glass-like effects.
311///
312/// ## Usage
313///
314/// Use to create a dynamic, layered background for other components.
315///
316/// ## Parameters
317///
318/// - `args` — configures the glass effect's appearance; see [`FluidGlassArgs`].
319/// - `ripple_state` — an optional [`RippleState`] to manage a ripple animation on interaction.
320/// - `child` — a closure that renders content on top of the glass surface.
321///
322/// ## Examples
323///
324/// ```
325/// use tessera_ui_basic_components::{
326///     fluid_glass::{fluid_glass, FluidGlassArgs},
327///     text::{text, TextArgsBuilder},
328/// };
329///
330/// fluid_glass(FluidGlassArgs::default(), None, || {
331///     text(TextArgsBuilder::default().text("Content on glass".to_string()).build().expect("builder construction failed"));
332/// });
333/// ```
334#[tessera]
335pub fn fluid_glass(
336    mut args: FluidGlassArgs,
337    ripple_state: Option<RippleState>,
338    child: impl FnOnce(),
339) {
340    if let Some(ripple_state) = &ripple_state
341        && let Some((progress, center)) = ripple_state.get_animation_progress()
342    {
343        args.ripple_center = Some(center);
344        args.ripple_radius = Some(progress);
345        args.ripple_alpha = Some((1.0 - progress) * 0.3);
346        args.ripple_strength = Some(progress);
347    }
348    (child)();
349    let args_measure_clone = args.clone();
350    measure(Box::new(move |input| {
351        let glass_intrinsic_width = args_measure_clone.width;
352        let glass_intrinsic_height = args_measure_clone.height;
353        let glass_intrinsic_constraint =
354            Constraint::new(glass_intrinsic_width, glass_intrinsic_height);
355        let effective_glass_constraint = glass_intrinsic_constraint.merge(input.parent_constraint);
356
357        let child_constraint = Constraint::new(
358            remove_padding_from_dimension(
359                effective_glass_constraint.width,
360                args_measure_clone.padding.into(),
361            ),
362            remove_padding_from_dimension(
363                effective_glass_constraint.height,
364                args_measure_clone.padding.into(),
365            ),
366        );
367
368        let child_measurement = if !input.children_ids.is_empty() {
369            let child_measurement =
370                input.measure_child(input.children_ids[0], &child_constraint)?;
371            input.place_child(
372                input.children_ids[0],
373                PxPosition {
374                    x: args.padding.into(),
375                    y: args.padding.into(),
376                },
377            );
378            child_measurement
379        } else {
380            ComputedData {
381                width: Px(0),
382                height: Px(0),
383            }
384        };
385
386        if args.blur_radius > Dp(0.0) {
387            let blur_command =
388                DualBlurCommand::horizontal_then_vertical(args.blur_radius.to_pixels_f32());
389            let mut metadata = input.metadata_mut();
390            metadata.push_compute_command(blur_command);
391        }
392
393        if let Some(contrast_value) = args.contrast
394            && contrast_value != 1.0
395        {
396            let mean_command =
397                MeanCommand::new(input.gpu, &mut input.compute_resource_manager.write());
398            let contrast_command =
399                ContrastCommand::new(contrast_value, mean_command.result_buffer_ref());
400            let mut metadata = input.metadata_mut();
401            metadata.push_compute_command(mean_command);
402            metadata.push_compute_command(contrast_command);
403        }
404
405        let drawable = FluidGlassCommand {
406            args: args_measure_clone.clone(),
407        };
408
409        input.metadata_mut().push_draw_command(drawable);
410
411        let padding_px: Px = args_measure_clone.padding.into();
412        let min_width = child_measurement.width + padding_px * 2;
413        let min_height = child_measurement.height + padding_px * 2;
414        let width = match effective_glass_constraint.width {
415            DimensionValue::Fixed(value) => value,
416            DimensionValue::Wrap { min, max } => min
417                .unwrap_or(Px(0))
418                .max(min_width)
419                .min(max.unwrap_or(Px::MAX)),
420            DimensionValue::Fill { min, max } => max
421                .expect("Seems that you are trying to fill an infinite width, which is not allowed")
422                .max(min_width)
423                .max(min.unwrap_or(Px(0))),
424        };
425        let height = match effective_glass_constraint.height {
426            DimensionValue::Fixed(value) => value,
427            DimensionValue::Wrap { min, max } => min
428                .unwrap_or(Px(0))
429                .max(min_height)
430                .min(max.unwrap_or(Px::MAX)),
431            DimensionValue::Fill { min, max } => max
432                .expect(
433                    "Seems that you are trying to fill an infinite height, which is not allowed",
434                )
435                .max(min_height)
436                .max(min.unwrap_or(Px(0))),
437        };
438        Ok(ComputedData { width, height })
439    }));
440
441    if let Some(ref on_click) = args.on_click {
442        let ripple_state = ripple_state.clone();
443        let on_click_arc = on_click.clone();
444        let args_for_handler = args.clone();
445        input_handler(Box::new(move |mut input: tessera_ui::InputHandlerInput| {
446            // Apply accessibility first
447            apply_fluid_glass_accessibility(
448                &mut input,
449                &args_for_handler,
450                &args_for_handler.on_click,
451            );
452            // Then handle click state (which includes block_input logic)
453            handle_click_state(
454                &args_for_handler,
455                ripple_state.clone(),
456                on_click_arc.clone(),
457                &mut input,
458            );
459        }));
460    } else if args.block_input {
461        let args_for_handler = args.clone();
462        input_handler(Box::new(move |mut input: tessera_ui::InputHandlerInput| {
463            // Apply accessibility first
464            apply_fluid_glass_accessibility(&mut input, &args_for_handler, &None);
465            // Then handle input blocking behavior
466            handle_block_input(&mut input);
467        }));
468    } else {
469        // Only accessibility metadata, no interaction
470        let args_for_handler = args.clone();
471        input_handler(Box::new(move |mut input: tessera_ui::InputHandlerInput| {
472            apply_fluid_glass_accessibility(&mut input, &args_for_handler, &None);
473        }));
474    }
475}