Skip to main content

tessera_components/
fluid_glass.rs

1//! A component for creating a frosted liquid lens glass visual effect.
2//!
3//! ## Usage
4//!
5//! Use as a background for buttons, panels, or other UI elements.
6use tessera_ui::{
7    Callback, Color, ComputedData, Constraint, CursorEventContent, Dp, FocusRequester,
8    LayoutResult, MeasurementError, Modifier, PointerEventPass, PointerId, PointerInput,
9    PointerInputModifierNode, PressKeyEventType, Px, PxPosition, RenderSlot, State,
10    accesskit::Role,
11    current_frame_nanos,
12    layout::{MeasureScope, layout},
13    modifier::ModifierCapabilityExt as _,
14    receive_frame_nanos, remember, tessera, use_context,
15};
16
17use crate::{
18    modifier::{ClickableArgs, InteractionState, ModifierExt, PointerEventContext, SemanticsArgs},
19    padding_utils::remove_padding_from_constraint,
20    pipelines::{
21        blur::command::DualBlurCommand,
22        contrast::ContrastCommand,
23        fluid_glass::{FluidGlassCommand, FluidGlassRenderArgs},
24    },
25    pos_misc::is_position_inside_bounds,
26    ripple_state::RippleState,
27    shape_def::{RoundedCorner, Shape},
28    theme::MaterialTheme,
29};
30
31/// Selects how the glass filters its backdrop.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub enum GlassMaterial {
34    /// Frosted glass for controls and surfaces that need stronger separation.
35    #[default]
36    Regular,
37    /// More transparent glass that retains more of the backdrop's color and contrast.
38    Clear,
39    /// A refractive lens with no default blur or tone adjustment.
40    Custom,
41}
42
43/// Selects the glass material's light or dark treatment.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum GlassAppearance {
46    /// A brighter glass surface for use with dark content.
47    Light,
48    /// A darker glass surface for use with light content.
49    Dark,
50}
51
52impl GlassMaterial {
53    fn optics(self, appearance: GlassAppearance) -> Option<(Dp, [f32; 4])> {
54        let (radius, tone) = match (self, appearance) {
55            (Self::Regular, GlassAppearance::Light) => (4.43, [0.6736, 0.2956, 0.6534, 0.995]),
56            (Self::Regular, GlassAppearance::Dark) => (4.43, [0.1316, 0.2600, 0.6488, 1.0183]),
57            (Self::Clear, GlassAppearance::Light) => (13.22, [0.3126, 0.6380, 0.8613, 1.0538]),
58            (Self::Clear, GlassAppearance::Dark) => (13.22, [0.1325, 0.6730, 0.9097, 1.0543]),
59            (Self::Custom, _) => return None,
60        };
61        Some((Dp(radius), tone))
62    }
63}
64
65/// Border properties applied to the glass surface.
66///
67/// # Example
68///
69/// ```
70/// use tessera_components::fluid_glass::GlassBorder;
71/// use tessera_ui::Px;
72///
73/// let border = GlassBorder::new(Px(2)); // Creates a border with 2 physical pixels width
74/// ```
75#[derive(Clone, Copy, Debug, Default, PartialEq)]
76pub struct GlassBorder {
77    /// Border width in physical pixels.
78    pub width: Px,
79}
80
81impl GlassBorder {
82    /// Creates a new border with the given width.
83    pub fn new(width: Px) -> Self {
84        Self { width }
85    }
86}
87
88impl Default for FluidGlassRenderArgs {
89    fn default() -> Self {
90        Self {
91            tint_color: Color::TRANSPARENT,
92            shape: default_glass_shape(),
93            noise_amount: 0.0,
94            noise_scale: 1.0,
95            time: 0.0,
96            ripple_center: None,
97            ripple_radius: None,
98            ripple_alpha: None,
99            ripple_strength: None,
100            border: default_glass_border(),
101            material_tone: None,
102            blur_sigma_px: 0.0,
103            pixels_per_dp: 1.0,
104            opacity: 1.0,
105            contrast: None,
106            dark: false,
107            feedback: 0.0,
108        }
109    }
110}
111
112fn default_glass_shape() -> Shape {
113    Shape::RoundedRectangle {
114        top_left: RoundedCorner::manual(Dp(25.0), 3.0),
115        top_right: RoundedCorner::manual(Dp(25.0), 3.0),
116        bottom_right: RoundedCorner::manual(Dp(25.0), 3.0),
117        bottom_left: RoundedCorner::manual(Dp(25.0), 3.0),
118    }
119}
120
121fn default_glass_border() -> Option<GlassBorder> {
122    Some(GlassBorder {
123        width: Dp(1.35).into(),
124    })
125}
126
127struct FluidGlassBlockInputPointerModifierNode;
128
129impl PointerInputModifierNode for FluidGlassBlockInputPointerModifierNode {
130    fn on_pointer_input(&self, mut input: PointerInput<'_>) {
131        let size = input.computed_data;
132        let cursor_pos_option = input.cursor_position_rel;
133        let is_cursor_in = cursor_pos_option
134            .map(|pos| is_position_inside_bounds(size, pos))
135            .unwrap_or(false);
136
137        if is_cursor_in {
138            // Consume all input events to prevent interaction with underlying
139            // components
140            input.block_all();
141        }
142    }
143}
144
145fn apply_fluid_glass_block_input_modifier(base: Modifier, block_input: bool) -> Modifier {
146    if block_input {
147        base.push_pointer_input(FluidGlassBlockInputPointerModifierNode)
148    } else {
149        base
150    }
151}
152
153struct GlassFeedbackPointer {
154    active: State<Option<PointerId>>,
155    ripple: State<RippleState>,
156}
157
158impl PointerInputModifierNode for GlassFeedbackPointer {
159    fn on_pointer_input(&self, input: PointerInput<'_>) {
160        if input.pass != PointerEventPass::Main {
161            return;
162        }
163        for change in input.pointer_changes.iter() {
164            let active = self.active.get();
165            match change.content {
166                CursorEventContent::Pressed(PressKeyEventType::Left)
167                    if active.is_none() && !change.is_consumed() =>
168                {
169                    let position = change
170                        .position()
171                        .map(|point| input.local_to_window.inverse_point(point))
172                        .or(input.cursor_position_rel);
173                    if let Some(position) = position.filter(|position| {
174                        is_position_inside_bounds(input.computed_data, *position)
175                    }) {
176                        let size = input.computed_data;
177                        self.active.set(Some(change.pointer_id));
178                        start_glass_feedback(
179                            self.ripple,
180                            [
181                                position.x / size.width.to_f32().max(1.0),
182                                position.y / size.height.to_f32().max(1.0),
183                            ],
184                        );
185                    }
186                }
187                CursorEventContent::Released(PressKeyEventType::Left)
188                | CursorEventContent::Cancelled(PressKeyEventType::Left)
189                    if active == Some(change.pointer_id) =>
190                {
191                    self.active.set(None);
192                    self.ripple.with_mut(RippleState::release);
193                }
194                _ => {}
195            }
196        }
197    }
198}
199
200fn start_glass_feedback(state: State<RippleState>, center: [f32; 2]) {
201    state.with_mut(|ripple| {
202        ripple.set_pressed(true);
203        ripple.start_animation(center);
204    });
205}
206
207/// # fluid_glass
208///
209/// Renders a highly customizable surface with blur, tint, and a liquid lens
210/// deformation effect.
211///
212/// ## Usage
213///
214/// Use to create a dynamic, layered background for other components.
215///
216/// ## Parameters
217///
218/// - `tint_color` — optional glass tint color.
219/// - `shape` — optional glass shape. Regular and Clear default to a capsule.
220/// - `material` — backdrop treatment, defaults to [`GlassMaterial::Regular`].
221/// - `appearance` — override the inherited theme appearance; defaults to light without a theme.
222/// - `blur_radius` — override the material's blur radius. Zero disables backdrop blur.
223/// - `noise_amount` — optional noise amount.
224/// - `noise_scale` — optional noise scale.
225/// - `time` — optional animated time input.
226/// - `contrast` — optional contrast adjustment.
227/// - `modifier` — modifier chain applied to the glass subtree.
228/// - `padding` — optional inner padding.
229/// - `ripple_center` — optional normalized ripple center.
230/// - `ripple_radius` — optional normalized ripple radius.
231/// - `ripple_alpha` — optional ripple alpha.
232/// - `ripple_strength` — optional ripple strength.
233/// - `on_click` — optional click callback.
234/// - `interactive` — enables optical press feedback. Defaults to true when `on_click` is set.
235/// - `border` — optional glass outline.
236/// - `block_input` — optional input blocking flag.
237/// - `accessibility_role` — optional accessibility role.
238/// - `accessibility_label` — optional accessibility label.
239/// - `accessibility_description` — optional accessibility description.
240/// - `accessibility_focusable` — optional accessibility focusable flag.
241/// - `child` — optional child render slot.
242///
243/// ## Examples
244///
245/// ```
246/// use tessera_components::{fluid_glass::{GlassMaterial, fluid_glass}, text::text};
247///
248/// # use tessera_ui::tessera;
249/// # #[tessera]
250/// # fn component() {
251/// fluid_glass().material(GlassMaterial::Clear).child(|| {
252///     text("Content on glass");
253/// });
254/// # }
255/// # tessera_ui::testing::layout_test(component).run();
256/// ```
257#[tessera]
258pub fn fluid_glass(
259    tint_color: Option<Color>,
260    shape: Option<Shape>,
261    material: Option<GlassMaterial>,
262    appearance: Option<GlassAppearance>,
263    blur_radius: Option<Dp>,
264    noise_amount: Option<f32>,
265    noise_scale: Option<f32>,
266    time: Option<f32>,
267    contrast: Option<f32>,
268    modifier: Option<Modifier>,
269    padding: Option<Dp>,
270    ripple_center: Option<[f32; 2]>,
271    ripple_radius: Option<f32>,
272    ripple_alpha: Option<f32>,
273    ripple_strength: Option<f32>,
274    on_click: Option<Callback>,
275    interactive: Option<bool>,
276    border: Option<GlassBorder>,
277    block_input: Option<bool>,
278    accessibility_role: Option<Role>,
279    #[prop(into)] accessibility_label: Option<String>,
280    #[prop(into)] accessibility_description: Option<String>,
281    accessibility_focusable: Option<bool>,
282    child: Option<RenderSlot>,
283) {
284    let tint_color = tint_color.unwrap_or(Color::TRANSPARENT);
285    let material = material.unwrap_or_default();
286    let shape = shape.unwrap_or_else(|| match material {
287        GlassMaterial::Regular | GlassMaterial::Clear => Shape::CAPSULE,
288        GlassMaterial::Custom => default_glass_shape(),
289    });
290    let appearance = if let Some(appearance) = appearance {
291        appearance
292    } else if use_context::<MaterialTheme>().is_some_and(|theme| theme.get().color_scheme.is_dark) {
293        GlassAppearance::Dark
294    } else {
295        GlassAppearance::Light
296    };
297    let optics = material.optics(appearance);
298    let blur_radius = blur_radius.unwrap_or(optics.map_or(Dp(0.0), |(radius, _)| radius));
299    let noise_amount = noise_amount.unwrap_or(0.0);
300    let noise_scale = noise_scale.unwrap_or(1.0);
301    let time = time.unwrap_or(0.0);
302    let padding = padding.unwrap_or(Dp(0.0));
303    let border = border.or_else(default_glass_border);
304    let block_input = block_input.unwrap_or(false);
305    let accessibility_focusable = accessibility_focusable.unwrap_or(false);
306    let mut modifier = modifier.unwrap_or_default();
307    let clickable = on_click.is_some();
308    let feedback_enabled = interactive.unwrap_or(clickable);
309    let focus_requester = remember(FocusRequester::new).get();
310    let interaction_state = if clickable {
311        Some(remember(InteractionState::new))
312    } else {
313        None
314    };
315    let ripple_state = if feedback_enabled {
316        Some(remember(RippleState::new))
317    } else {
318        None
319    };
320    let has_semantics = accessibility_role.is_some()
321        || accessibility_label.is_some()
322        || accessibility_description.is_some()
323        || accessibility_focusable;
324
325    if let Some(on_click) = on_click {
326        let press_handler = ripple_state.map(|state| {
327            move |ctx: PointerEventContext| {
328                start_glass_feedback(state, ctx.normalized_pos);
329            }
330        });
331        let release_handler = ripple_state.map(|state| {
332            move |_ctx: PointerEventContext| {
333                state.with_mut(|s| s.release());
334            }
335        });
336        let clickable_args = ClickableArgs {
337            on_click,
338            block_input,
339            on_press: press_handler.map(Into::into),
340            on_release: release_handler.map(Into::into),
341            role: accessibility_role.or_else(|| accessibility_focusable.then_some(Role::Button)),
342            label: accessibility_label.clone(),
343            description: accessibility_description.clone(),
344            interaction_state,
345            focus_requester: Some(focus_requester),
346            ..Default::default()
347        };
348
349        modifier = modifier.clickable_with(clickable_args);
350    } else {
351        if block_input {
352            modifier = modifier.block_touch_propagation();
353        }
354        if let Some(ripple) = ripple_state {
355            let active = remember(|| None::<PointerId>);
356            modifier = modifier.push_pointer_input(GlassFeedbackPointer { active, ripple });
357        }
358    }
359    if !clickable && has_semantics {
360        let semantics = SemanticsArgs {
361            role: accessibility_role.or_else(|| accessibility_focusable.then_some(Role::Button)),
362            label: accessibility_label.clone(),
363            description: accessibility_description.clone(),
364            focusable: accessibility_focusable,
365            ..Default::default()
366        };
367        modifier = modifier.semantics(semantics);
368    }
369
370    let render = FluidGlassRenderArgs {
371        tint_color,
372        shape,
373        noise_amount,
374        noise_scale,
375        time,
376        ripple_center,
377        ripple_radius,
378        ripple_alpha,
379        ripple_strength,
380        border,
381        material_tone: optics.map(|(_, tone)| tone),
382        blur_sigma_px: blur_radius.to_pixels_f32().max(0.0),
383        pixels_per_dp: Dp(1.0).to_pixels_f32(),
384        opacity: 1.0,
385        contrast,
386        dark: appearance == GlassAppearance::Dark,
387        feedback: 0.0,
388    };
389
390    layout().modifier(modifier).child(move || {
391        fluid_glass_inner(render.clone())
392            .padding(padding)
393            .clickable(clickable)
394            .block_input(block_input)
395            .ripple_state_optional(ripple_state)
396            .interaction_state_optional(interaction_state)
397            .child_optional(child);
398    });
399}
400
401#[tessera]
402fn fluid_glass_inner(
403    render: FluidGlassRenderArgs,
404    padding: Option<Dp>,
405    clickable: Option<bool>,
406    block_input: Option<bool>,
407    ripple_state: Option<State<RippleState>>,
408    interaction_state: Option<State<InteractionState>>,
409    child: Option<RenderSlot>,
410) {
411    let padding = padding.unwrap_or(Dp(0.0));
412    let clickable = clickable.unwrap_or(false);
413    let block_input = block_input.unwrap_or(false);
414    let mut render = render;
415    if let Some(ripple) = ripple_state {
416        let (pressed, hovered) = if let Some(interaction) = interaction_state {
417            interaction.with(|state| (state.is_pressed(), state.is_hovered()))
418        } else {
419            (ripple.with(RippleState::is_pressed), false)
420        };
421        render.feedback = if pressed {
422            0.16
423        } else if hovered {
424            0.025
425        } else {
426            0.0
427        };
428    }
429    let frame_nanos = current_frame_nanos();
430    if let Some((progress, center)) = ripple_state.as_ref().and_then(|state| {
431        state.with_mut(|ripple| {
432            ripple
433                .animation_at_frame_nanos(frame_nanos)
434                .map(|animation| (animation.progress, animation.center))
435        })
436    }) {
437        if let Some(ripple_state) = ripple_state {
438            receive_frame_nanos(move |frame_nanos| {
439                let has_active_ripple = ripple_state
440                    .with_mut(|ripple| ripple.animation_at_frame_nanos(frame_nanos).is_some());
441                if has_active_ripple {
442                    tessera_ui::FrameNanosControl::Continue
443                } else {
444                    tessera_ui::FrameNanosControl::Stop
445                }
446            });
447        }
448
449        render.ripple_center = Some(center);
450        render.ripple_radius = Some(progress);
451        render.ripple_alpha = Some((1.0 - progress) * 0.3);
452        render.ripple_strength = Some(4.0 * progress * (1.0 - progress));
453    }
454    let modifier =
455        apply_fluid_glass_block_input_modifier(Modifier::new(), !clickable && block_input);
456    let policy = FluidGlassLayout {
457        render: render.clone(),
458        padding,
459    };
460    layout()
461        .modifier(modifier)
462        .policy(policy.clone())
463        .draw(policy)
464        .child(move || {
465            if let Some(child) = child.as_ref() {
466                child.render();
467            }
468        });
469}
470
471#[derive(Clone, PartialEq)]
472struct FluidGlassLayout {
473    render: FluidGlassRenderArgs,
474    padding: Dp,
475}
476
477impl tessera_ui::LayoutModifierNode for FluidGlassLayout {
478    fn measure_eq(&self, other: &Self) -> bool {
479        self.padding == other.padding
480    }
481
482    fn placement_eq(&self, other: &Self) -> bool {
483        self.measure_eq(other)
484    }
485
486    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
487        let mut result = LayoutResult::default();
488        let parent_constraint = *input.parent_constraint().as_ref();
489
490        let child_constraint = Constraint::new(
491            remove_padding_from_constraint(parent_constraint.width, self.padding.into()),
492            remove_padding_from_constraint(parent_constraint.height, self.padding.into()),
493        );
494
495        let children = input.children();
496        let child_measurement = if let Some(&child) = children.first() {
497            let child_measurement = child.measure(input, &child_constraint)?;
498            result.place_child(
499                child,
500                PxPosition::from_f32_xy(self.padding.to_pixels_f32(), self.padding.to_pixels_f32()),
501            );
502            child_measurement.size()
503        } else {
504            ComputedData {
505                width: Px(0),
506                height: Px(0),
507            }
508        };
509
510        let padding_px: Px = self.padding.into();
511        let min_width = child_measurement.width + padding_px * 2;
512        let min_height = child_measurement.height + padding_px * 2;
513        let width = parent_constraint.width.clamp(min_width);
514        let height = parent_constraint.height.clamp(min_height);
515
516        Ok(result.with_size(ComputedData { width, height }))
517    }
518}
519
520impl tessera_ui::DrawModifierNode for FluidGlassLayout {
521    fn draw(
522        &self,
523        ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
524        content: &mut dyn tessera_ui::DrawModifierContent,
525    ) {
526        let input = &mut *ctx.render_input;
527        let mut render = self.render.clone();
528        if render.material_tone.is_none() {
529            if render.blur_sigma_px > 0.0 {
530                input.metadata_mut().fragment_mut().push_compute_command(
531                    DualBlurCommand::horizontal_then_vertical(render.blur_sigma_px),
532                );
533                render.blur_sigma_px = 0.0;
534            }
535            if let Some(contrast) = render.contrast.take().filter(|value| *value != 1.0) {
536                input
537                    .metadata_mut()
538                    .fragment_mut()
539                    .push_compute_command(ContrastCommand::new(contrast));
540            }
541        }
542        let drawable = FluidGlassCommand { render };
543
544        input
545            .metadata_mut()
546            .fragment_mut()
547            .push_draw_command(drawable);
548        content.draw(input);
549    }
550}