1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub enum GlassMaterial {
34 #[default]
36 Regular,
37 Clear,
39 Custom,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum GlassAppearance {
46 Light,
48 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#[derive(Clone, Copy, Debug, Default, PartialEq)]
76pub struct GlassBorder {
77 pub width: Px,
79}
80
81impl GlassBorder {
82 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 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#[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}