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}