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