Skip to main content

cranpose_ui/modifier/
graphics_layer.rs

1use std::{rc::Rc, sync::Arc};
2
3use cranpose_ui_graphics::{
4    BlendMode, Color, ColorFilter, CompositingStrategy, Density, Dp, GradientBlurDirection,
5    GradientCutMaskSpec, GradientFadeMaskSpec, LayerShape, Px, RenderEffect, RoundedCornerShape,
6    RuntimeShader, TransformOrigin, gradient_blur_effect, gradient_cut_mask_effect,
7    gradient_fade_dst_out_effect, rounded_alpha_mask_effect,
8    space::{LayerSpace, Mat4, MeshSurface, Scene3d},
9};
10
11use super::{GraphicsLayer, Modifier, inspector_metadata};
12use crate::modifier_nodes::{GraphicsLayerElement, LazyGraphicsLayerElement};
13
14fn backdrop_blur_layer(radius: Dp, shape: LayerShape) -> GraphicsLayer {
15    let density = Density::from_scale(crate::render_state::current_density());
16    let radius_px = radius.to_px(density).max(Px::ZERO);
17    GraphicsLayer {
18        backdrop_effect: (radius_px.0 > 0.0).then(|| RenderEffect::blur(radius_px.0)),
19        shape,
20        clip: true,
21        ..Default::default()
22    }
23}
24
25fn backdrop_gradient_blur_layer(
26    start_radius: Dp,
27    end_radius: Dp,
28    direction: GradientBlurDirection,
29) -> GraphicsLayer {
30    let density = Density::from_scale(crate::render_state::current_density());
31    let start_px = start_radius.to_px(density).max(Px::ZERO);
32    let end_px = end_radius.to_px(density).max(Px::ZERO);
33    GraphicsLayer {
34        backdrop_effect: (start_px.0 > 0.0 || end_px.0 > 0.0)
35            .then(|| gradient_blur_effect(start_px.0, end_px.0, direction)),
36        shape: LayerShape::Rectangle,
37        clip: true,
38        ..Default::default()
39    }
40}
41
42impl Modifier {
43    /// Apply a lazily evaluated graphics layer.
44    ///
45    /// The closure is evaluated during scene building, not composition, which lets
46    /// layer properties update without forcing recomposition.
47    ///
48    /// Example:
49    /// `Modifier::empty().graphics_layer(|| GraphicsLayer { alpha: 0.5, ..Default::default() })`
50    ///
51    /// # A drag that drives the translation must not be read inside it
52    ///
53    /// [`PointerEvent::position`](crate::modifier::PointerEvent::position) is
54    /// in the receiving node's own space, so a node this layer translates
55    /// reports a finger that has moved only as far as it OUTRAN the
56    /// translation. Feed that position back into `translation_x` and the
57    /// gesture measures itself:
58    ///
59    /// ```text
60    /// reported = finger - offset          offset = reported - start
61    ///         => offset = (finger - start) / 2
62    /// ```
63    ///
64    /// The offset converges on half the finger's travel and can never exceed
65    /// it, so a swipe-to-dismiss whose threshold is half the width never
66    /// crosses it however far the finger goes -- silently, with the content
67    /// sliding convincingly the whole time.
68    ///
69    /// Two ways out, and [`SwipeToDismiss`](crate::widgets::SwipeToDismiss)
70    /// uses both:
71    ///
72    /// - put `pointer_input` on a node OUTSIDE the translated one, which is
73    ///   also how Compose's own `SwipeToDismissBox` is built -- the drag is
74    ///   detected on the box, the content carries the layer;
75    /// - or read
76    ///   [`PointerEvent::global_position`](crate::modifier::PointerEvent::global_position),
77    ///   which no layer transform touches.
78    pub fn graphics_layer(self, layer: impl Fn() -> GraphicsLayer + 'static) -> Self {
79        let modifier = Self::with_element(LazyGraphicsLayerElement::new(Rc::new(layer)))
80            .with_inspector_metadata(inspector_metadata("graphicsLayer", |info| {
81                info.add_property("lazy", "true");
82            }));
83        self.then(modifier)
84    }
85
86    /// Shows a 3D scene in this node's bounds.
87    ///
88    /// Every node inside the scene takes a 3D frame from its parent's: its
89    /// place in its parent's layout, on the parent's plane, with x right, y
90    /// down and z away from the side the content shows to, a unit to a
91    /// logical pixel. The scene's own frame is its space, which the camera
92    /// looks into through the middle of the node's bounds; a camera one
93    /// focal length in front of the plane z = 0 shows the scene's children
94    /// at their own size, where layout put them. A child with a
95    /// [`mesh_surface`](Self::mesh_surface) draws its content on its mesh in
96    /// its frame, and nearer surfaces hide farther ones; a
97    /// [`transform3d`](Self::transform3d) moves the frames beneath it. The
98    /// scene is cut to the node's bounds, and the node's own draws and its
99    /// children's flat draws stay flat: those that come before the first
100    /// child in 3D are drawn under the scene, the rest over it.
101    ///
102    /// A `scene3d` inside the scene is a doorway: its node's rect, in its
103    /// frame, opens onto a space of its own, which lies behind it, placed so
104    /// the inner scene's camera stands in the doorway's middle (see
105    /// [`Camera::doorway`](cranpose_ui_graphics::space::Camera::doorway)).
106    /// The window's eye sees through each doorway that shows on screen, as
107    /// deep as they show, and walks through them (see [`Eye`](crate::Eye)).
108    ///
109    /// The closure runs when the scene is drawn, as a lazy
110    /// [`graphics_layer`](Self::graphics_layer)'s does, so a camera that
111    /// follows a state read in it moves without recomposition.
112    ///
113    /// ```
114    /// use std::sync::Arc;
115    ///
116    /// use cranpose_ui::Modifier;
117    /// use cranpose_ui_graphics::space::{Camera, Mat4, Material, Mesh, MeshSurface, Scene3d, Vec3};
118    ///
119    /// let camera = Camera::look_at(
120    ///     Vec3::new(0.0, 0.0, -800.0),
121    ///     Vec3::ZERO,
122    ///     Vec3::new(0.0, 1.0, 0.0),
123    ///     700.0,
124    /// );
125    /// let scene = Modifier::empty().scene3d(move || Scene3d {
126    ///     camera,
127    ///     light: None,
128    ///     eye: None,
129    /// });
130    /// let sphere = Arc::new(Mesh::sphere(48, 24));
131    /// let globe = Modifier::empty().mesh_surface(move || MeshSurface {
132    ///     mesh: Arc::clone(&sphere),
133    ///     transform: Mat4::scaling(Vec3::new(200.0, 200.0, 200.0)),
134    ///     material: Material::Lit,
135    ///     casts_shadow: true,
136    ///     solid: true,
137    /// });
138    /// # let _ = (scene, globe);
139    /// ```
140    pub fn scene3d(self, scene: impl Fn() -> Scene3d + 'static) -> Self {
141        self.graphics_layer(move || GraphicsLayer {
142            space: Some(Arc::new(LayerSpace::Scene(scene()))),
143            clip: true,
144            ..GraphicsLayer::DEFAULT
145        })
146    }
147
148    /// Draws this node's content on a mesh in its scene, placed in the
149    /// node's 3D frame (see [`scene3d`](Self::scene3d)).
150    ///
151    /// The node's size is the size of the content laid on the mesh: content
152    /// point `(x, y)` shows where the mesh's texture coordinate is
153    /// `(x / width, y / height)`. The surface's transform places the mesh in
154    /// the node's frame, which starts where layout placed the node. The
155    /// content stays live and takes pointer input where it shows on the
156    /// mesh. The frames of the node's children lie in its content: x and y
157    /// in its content's pixels, z into it (see
158    /// [`MeshSurface::content_to_scene`](cranpose_ui_graphics::space::MeshSurface::content_to_scene)),
159    /// so a control can stand out of the wall it sits on, where layout put
160    /// it on the wall. A node outside every scene draws its content flat.
161    ///
162    /// The closure runs when the scene is drawn, as a lazy
163    /// [`graphics_layer`](Self::graphics_layer)'s does.
164    pub fn mesh_surface(self, surface: impl Fn() -> MeshSurface + 'static) -> Self {
165        self.graphics_layer(move || GraphicsLayer {
166            space: Some(Arc::new(LayerSpace::Surface(surface()))),
167            clip: true,
168            ..GraphicsLayer::DEFAULT
169        })
170    }
171
172    /// Moves the 3D frames beneath this node by `transform`, in the node's
173    /// own frame, as a group of 3D objects moves together: the mesh surfaces
174    /// and doorways under it follow. The node's own draws and the flat draws
175    /// beneath it stay where they would be without it, and outside a scene
176    /// it changes nothing.
177    ///
178    /// The closure runs when the scene is drawn, as a lazy
179    /// [`graphics_layer`](Self::graphics_layer)'s does, so a group that
180    /// follows a state read in it moves without recomposition.
181    pub fn transform3d(self, transform: impl Fn() -> Mat4 + 'static) -> Self {
182        self.graphics_layer(move || GraphicsLayer {
183            space: Some(Arc::new(LayerSpace::Transform(transform()))),
184            ..GraphicsLayer::DEFAULT
185        })
186    }
187
188    /// Apply a concrete graphics layer snapshot.
189    ///
190    /// This is useful for parameter-style wrappers that already build a fixed
191    /// [`GraphicsLayer`] value.
192    pub fn graphics_layer_value(self, layer: GraphicsLayer) -> Self {
193        let inspector_values = layer.clone();
194        let modifier = Self::with_element(GraphicsLayerElement::new(layer))
195            .with_inspector_metadata(inspector_metadata("graphicsLayer", move |info| {
196                info.add_property("alpha", inspector_values.alpha.to_string());
197                info.add_property("scale", inspector_values.scale.to_string());
198                info.add_property("scaleX", inspector_values.scale_x.to_string());
199                info.add_property("scaleY", inspector_values.scale_y.to_string());
200                info.add_property("rotationX", inspector_values.rotation_x.to_string());
201                info.add_property("rotationY", inspector_values.rotation_y.to_string());
202                info.add_property("rotationZ", inspector_values.rotation_z.to_string());
203                info.add_property(
204                    "cameraDistance",
205                    inspector_values.camera_distance.to_string(),
206                );
207                info.add_property(
208                    "transformOrigin",
209                    format!(
210                        "{},{}",
211                        inspector_values.transform_origin.pivot_fraction_x,
212                        inspector_values.transform_origin.pivot_fraction_y
213                    ),
214                );
215                info.add_property("translationX", inspector_values.translation_x.to_string());
216                info.add_property("translationY", inspector_values.translation_y.to_string());
217                if let Some(space) = &inspector_values.space {
218                    info.add_property("space", format!("{space:?}"));
219                }
220                info.add_property(
221                    "shadowElevation",
222                    inspector_values.shadow_elevation.to_string(),
223                );
224                info.add_property("shape", format!("{:?}", inspector_values.shape));
225                info.add_property("clip", inspector_values.clip.to_string());
226                info.add_property(
227                    "ambientShadowColor",
228                    format!("{:?}", inspector_values.ambient_shadow_color),
229                );
230                info.add_property(
231                    "spotShadowColor",
232                    format!("{:?}", inspector_values.spot_shadow_color),
233                );
234                info.add_property(
235                    "compositingStrategy",
236                    format!("{:?}", inspector_values.compositing_strategy),
237                );
238                info.add_property("blendMode", format!("{:?}", inspector_values.blend_mode));
239                if let Some(filter) = inspector_values.color_filter {
240                    info.add_property("colorFilter", format!("{filter:?}"));
241                }
242            }));
243        self.then(modifier)
244    }
245
246    /// Compose-compatible parameter-style graphics layer entry point.
247    ///
248    /// This mirrors `Modifier.graphicsLayer(...)` style APIs and maps directly to
249    /// [`GraphicsLayer`] fields currently implemented by the renderer stack.
250    #[expect(clippy::too_many_arguments)]
251    pub fn graphics_layer_params(
252        self,
253        scale_x: f32,
254        scale_y: f32,
255        alpha: f32,
256        translation_x: f32,
257        translation_y: f32,
258        shadow_elevation: f32,
259        rotation_x: f32,
260        rotation_y: f32,
261        rotation_z: f32,
262        camera_distance: f32,
263        transform_origin: TransformOrigin,
264        shape: LayerShape,
265        clip: bool,
266        render_effect: Option<RenderEffect>,
267        ambient_shadow_color: Color,
268        spot_shadow_color: Color,
269        compositing_strategy: CompositingStrategy,
270        blend_mode: BlendMode,
271        color_filter: Option<ColorFilter>,
272    ) -> Self {
273        self.graphics_layer_value(GraphicsLayer {
274            alpha,
275            scale: 1.0,
276            scale_x,
277            scale_y,
278            rotation_x,
279            rotation_y,
280            rotation_z,
281            camera_distance,
282            transform_origin,
283            translation_x,
284            translation_y,
285            space: None,
286            shadow_elevation,
287            ambient_shadow_color,
288            spot_shadow_color,
289            shape,
290            clip,
291            compositing_strategy,
292            blend_mode,
293            color_filter,
294            render_effect,
295            backdrop_effect: None,
296        })
297    }
298
299    /// Compose-compatible block-style graphics layer entry point.
300    ///
301    /// Example:
302    /// `Modifier::empty().graphics_layer_block(|layer| { layer.alpha = 0.5; layer.scale_x = 1.2; })`
303    pub fn graphics_layer_block(self, configure: impl Fn(&mut GraphicsLayer) + 'static) -> Self {
304        self.graphics_layer(move || {
305            let mut layer = GraphicsLayer::default();
306            configure(&mut layer);
307            layer
308        })
309    }
310
311    /// Compose-style rotation convenience.
312    ///
313    /// Matches Kotlin: `Modifier.rotate(degrees: Float)`, which maps to
314    /// `graphicsLayer(rotationZ = degrees)`. Rotation is clockwise for
315    /// positive `degrees` and pivots around the layer's center, matching
316    /// `graphics_layer_value`'s default (centered) `transform_origin`.
317    pub fn rotate(self, degrees: f32) -> Self {
318        self.graphics_layer_value(GraphicsLayer {
319            rotation_z: degrees,
320            ..Default::default()
321        })
322    }
323
324    /// Compose-style uniform scale convenience.
325    ///
326    /// Matches Kotlin: `Modifier.scale(scale: Float)`, which maps to
327    /// `graphicsLayer(scaleX = scale, scaleY = scale)`.
328    pub fn scale(self, scale: f32) -> Self {
329        self.scale_xy(scale, scale)
330    }
331
332    /// Compose-style non-uniform scale convenience.
333    ///
334    /// Matches Kotlin: `Modifier.scale(scaleX: Float, scaleY: Float)`.
335    pub fn scale_xy(self, scale_x: f32, scale_y: f32) -> Self {
336        self.graphics_layer_value(GraphicsLayer {
337            scale_x,
338            scale_y,
339            ..Default::default()
340        })
341    }
342
343    /// Compose-style elevation shadow convenience.
344    ///
345    /// This mirrors `Modifier.shadow(elevation)` defaults:
346    /// rectangle shape, black ambient/spot colors, and clipping enabled when
347    /// elevation is positive.
348    pub fn shadow(self, elevation: f32) -> Self {
349        self.shadow_with(
350            elevation,
351            LayerShape::Rectangle,
352            elevation > 0.0,
353            Color::BLACK,
354            Color::BLACK,
355        )
356    }
357
358    /// Compose-style shadow API with explicit shape/clip/colors.
359    pub fn shadow_with(
360        self,
361        elevation: f32,
362        shape: LayerShape,
363        clip: bool,
364        ambient_color: Color,
365        spot_color: Color,
366    ) -> Self {
367        let clamped_elevation = elevation.max(0.0);
368        if clamped_elevation == 0.0 && !clip {
369            return self;
370        }
371
372        self.graphics_layer_value(GraphicsLayer {
373            shadow_elevation: clamped_elevation,
374            ambient_shadow_color: ambient_color,
375            spot_shadow_color: spot_color,
376            shape,
377            clip,
378            ..Default::default()
379        })
380    }
381
382    /// Apply a backdrop effect to content behind this composable's bounds.
383    pub fn backdrop_effect(self, effect: RenderEffect) -> Self {
384        let layer = GraphicsLayer {
385            backdrop_effect: Some(effect),
386            ..Default::default()
387        };
388        let modifier = Self::with_element(GraphicsLayerElement::new(layer))
389            .with_inspector_metadata(inspector_metadata("backdropEffect", |info| {
390                info.add_property("enabled", "true");
391            }));
392        self.then(modifier)
393    }
394
395    /// Blur content behind this composable, clipped to the composable bounds.
396    ///
397    /// `radius` is expressed in Dp and converted to px using the current render
398    /// density when modifier slices are evaluated.
399    pub fn backdrop_blur(self, radius: Dp) -> Self {
400        if radius.0 <= 0.0 {
401            return self.clip_to_bounds();
402        }
403
404        let modifier = Self::with_element(LazyGraphicsLayerElement::new(Rc::new(move || {
405            backdrop_blur_layer(radius, LayerShape::Rectangle)
406        })))
407        .with_inspector_metadata(inspector_metadata("backdropBlur", move |info| {
408            info.add_property("radius", radius.0.to_string());
409        }));
410        self.then(modifier)
411    }
412
413    /// Blur content behind this composable with a radius that changes across
414    /// its bounds. This is a true spatial blur gradient: the sampling kernel
415    /// interpolates from `start_radius` to `end_radius`; it is not an opacity
416    /// gradient over a uniformly blurred layer.
417    pub fn backdrop_gradient_blur(
418        self,
419        start_radius: Dp,
420        end_radius: Dp,
421        direction: GradientBlurDirection,
422    ) -> Self {
423        if start_radius.0 <= 0.0 && end_radius.0 <= 0.0 {
424            return self.clip_to_bounds();
425        }
426        let modifier = Self::with_element(LazyGraphicsLayerElement::new(Rc::new(move || {
427            backdrop_gradient_blur_layer(start_radius, end_radius, direction)
428        })))
429        .with_inspector_metadata(inspector_metadata(
430            "backdropGradientBlur",
431            move |info| {
432                info.add_property("startRadius", start_radius.0.to_string());
433                info.add_property("endRadius", end_radius.0.to_string());
434                info.add_property("direction", format!("{direction:?}"));
435            },
436        ));
437        self.then(modifier)
438    }
439
440    /// Apply a frosted glass material: backdrop blur, clipped shape, and tint.
441    pub fn glass_material(self, material: GlassMaterial) -> Self {
442        let blur_radius = material.blur_radius;
443        let shape = material.shape;
444        let modifier = Self::with_element(LazyGraphicsLayerElement::new(Rc::new(move || {
445            backdrop_blur_layer(blur_radius, LayerShape::Rounded(shape))
446        })))
447        .with_inspector_metadata(inspector_metadata("glassMaterial", move |info| {
448            info.add_property("blurRadius", blur_radius.0.to_string());
449            info.add_property("shape", format!("{shape:?}"));
450        }));
451
452        self.then(modifier)
453            .rounded_corner_shape(material.shape)
454            .background(material.tint)
455    }
456
457    /// Convenience alias for applying a backdrop shader effect.
458    pub fn shader_background(self, shader: RuntimeShader) -> Self {
459        self.backdrop_effect(RenderEffect::runtime_shader(shader))
460    }
461
462    /// Apply a color filter to this composable's graphics layer output.
463    ///
464    /// This mirrors Compose's `graphicsLayer(colorFilter = ...)` capability.
465    pub fn color_filter(self, filter: ColorFilter) -> Self {
466        let layer = GraphicsLayer {
467            color_filter: Some(filter),
468            ..Default::default()
469        };
470        let modifier = Self::with_element(GraphicsLayerElement::new(layer))
471            .with_inspector_metadata(inspector_metadata("colorFilter", |info| {
472                info.add_property("enabled", "true");
473            }));
474        self.then(modifier)
475    }
476
477    /// Convenience color filter that tints layer output.
478    pub fn tint(self, tint: Color) -> Self {
479        self.color_filter(ColorFilter::tint(tint))
480    }
481
482    /// Configures how the layer is composited into its parent.
483    pub fn compositing_strategy(self, strategy: CompositingStrategy) -> Self {
484        self.graphics_layer_value(GraphicsLayer {
485            compositing_strategy: strategy,
486            ..Default::default()
487        })
488    }
489
490    /// Configures blend mode for this layer output.
491    ///
492    /// Runtime support is backend-dependent. Current renderers fully support
493    /// `SrcOver` and `DstOut`; unsupported modes fall back to `SrcOver`.
494    pub fn layer_blend_mode(self, blend_mode: BlendMode) -> Self {
495        self.graphics_layer_value(GraphicsLayer {
496            blend_mode,
497            ..Default::default()
498        })
499    }
500
501    /// Apply a directional gradient cut mask to this composable output.
502    ///
503    /// This masks the rendered layer with rounded corners and a feathered edge.
504    pub fn gradient_cut_mask(
505        self,
506        area_width: f32,
507        area_height: f32,
508        spec: GradientCutMaskSpec,
509    ) -> Self {
510        let layer = GraphicsLayer {
511            render_effect: Some(gradient_cut_mask_effect(&spec, area_width, area_height)),
512            ..Default::default()
513        };
514        self.graphics_layer_value(layer)
515    }
516
517    /// Apply a rounded alpha mask to this composable output.
518    ///
519    /// Useful to constrain a preceding render effect (for example blur) to a
520    /// rounded shape while preserving a soft edge transition.
521    pub fn rounded_alpha_mask(
522        self,
523        area_width: f32,
524        area_height: f32,
525        corner_radius: f32,
526        edge_feather: f32,
527    ) -> Self {
528        let has_radius = corner_radius.is_finite() && corner_radius > 0.0;
529        let has_feather = edge_feather.is_finite() && edge_feather > 0.0;
530        if !has_radius && !has_feather {
531            return self;
532        }
533        let layer = GraphicsLayer {
534            render_effect: Some(rounded_alpha_mask_effect(
535                area_width,
536                area_height,
537                corner_radius,
538                edge_feather,
539            )),
540            ..Default::default()
541        };
542        self.graphics_layer_value(layer)
543    }
544
545    /// Apply a directional gradient fade mask with destination-out semantics.
546    ///
547    /// This mirrors Compose's `drawWithContent + drawRect(..., BlendMode.DstOut)`
548    /// pattern for fading content to transparent along one axis.
549    pub fn gradient_fade_dst_out(
550        self,
551        area_width: f32,
552        area_height: f32,
553        spec: GradientFadeMaskSpec,
554    ) -> Self {
555        let layer = GraphicsLayer {
556            render_effect: Some(gradient_fade_dst_out_effect(&spec, area_width, area_height)),
557            ..Default::default()
558        };
559        self.graphics_layer_value(layer)
560    }
561}
562
563/// Visual parameters for [`Modifier::glass_material`].
564#[derive(Clone, Copy, Debug, PartialEq)]
565pub struct GlassMaterial {
566    /// Radius of the blur applied to what is behind the node.
567    pub blur_radius: Dp,
568    /// Color drawn over the blurred backdrop as the node's background.
569    pub tint: Color,
570    /// Rounded corners that clip the blur and the tint.
571    pub shape: RoundedCornerShape,
572}
573
574impl GlassMaterial {
575    /// Creates a material from its blur radius, tint and shape.
576    pub fn new(blur_radius: Dp, tint: Color, shape: RoundedCornerShape) -> Self {
577        Self {
578            blur_radius,
579            tint,
580            shape,
581        }
582    }
583}