Skip to main content

bevy_react/layer/
clip.rs

1//! Interior clips — the main-world half of clip-independent layer capture.
2//!
3//! A promoted layer's capture must not bake **ancestor** clipping (scroll
4//! containers / viewport) into its cached pixels: the cache treats scroll as
5//! pure translation (see [`super::fold_member_geometry`]), so pixels captured
6//! under an ancestor clip would be stale the moment the layer scrolls. Web
7//! semantics agree: `filter` applies to the element's full painted content,
8//! and ancestor overflow clips the *result* at composite time.
9//!
10//! [`sync_layer_clips`] therefore re-runs `bevy_ui`'s clip cascade
11//! (`update_clipping` semantics: a node's inherited clip is its ancestors'
12//! overflow intersection; its **own** overflow only clips its children) —
13//! but restarted at each layer root with **no inherited clip**. The result
14//! is two per-frame maps in [`LayerClips`]:
15//!
16//! - `interior`: per subtree member, the clip its extracted items should use
17//!   *inside the capture* — clips originating inside the subtree still apply
18//!   (they move with the layer, so cached pixels stay valid); everything
19//!   above the root is stripped. Consumed by the render world's
20//!   extract-window swap (`layer::render::clip`), which substitutes these
21//!   into the members' [`CalculatedClip`] for exactly the duration of
22//!   `ExtractSchedule` — the main world is exclusively borrowed there, so no
23//!   main-world system (picking, focus) can ever observe the swap, and every
24//!   stock extractor (backgrounds, text, gradients, box shadows, texture
25//!   slices) picks the interior clips up uniformly. Text self-clips
26//!   (`TextScroll` content boxes) recompose automatically in the stock text
27//!   extractors from the swapped value.
28//! - `quads`: per layer root, the screen-space rect its **composite quad**
29//!   clamps to instead — a top-level root's inherited [`CalculatedClip`], or,
30//!   for a nested root, the enclosing layer's interior cascade value at the
31//!   root (its quad draws inside the enclosing capture).
32//!
33//! Outside the extract window the real `CalculatedClip` values are always in
34//! place: picking (`crate::pick_clip`) and `bevy_ui`'s own consumers keep
35//! seeing true inherited clips.
36//!
37//! Known divergence (deliberate, rare): an `OverrideClip` escapee inside a
38//! subtree is still clamped by the layer's quad clip at composite time
39//! (stock lets it escape every ancestor clip).
40//!
41//! Offscreen layers still capture (when dirty) like any other — a fully
42//! clipped-away quad simply draws nothing. The cost is invisible re-captures
43//! for continuously-animated offscreen layers; the win is that scrolling
44//! never re-captures and a layer scrolled into view is correct by
45//! construction (the bug this module fixes: captures taken under clipping
46//! were cached and served stale after scrolling).
47
48use bevy::math::Rect;
49use bevy::platform::collections::HashMap;
50use bevy::prelude::*;
51use bevy::ui::{CalculatedClip, ComputedNode, OverrideClip, UiGlobalTransform};
52
53use super::{LayerMembership, PromotedLayer};
54
55/// Per-frame clip maps for promoted layers, rebuilt from scratch by
56/// [`sync_layer_clips`] (like [`LayerMembership`] — nothing persists).
57#[derive(Resource, Debug, Default)]
58pub struct LayerClips {
59    /// Subtree member → the clip its extracted items use inside the capture
60    /// (`None` = unclipped there). Every member gets an entry; the swap only
61    /// touches members that carry a real [`CalculatedClip`] — a member whose
62    /// interior clip is `Some` always does (its interior clip sources are a
63    /// subset of the real cascade's), so no component insert/remove is ever
64    /// needed.
65    pub interior: HashMap<Entity, Option<Rect>>,
66    /// Promoted root → the screen-space rect its composite quad clamps to.
67    /// Top-level roots: their inherited [`CalculatedClip`]. Nested roots: the
68    /// enclosing layer's interior cascade value at the root. `None` inside
69    /// the option = unclipped.
70    pub quads: HashMap<Entity, Option<Rect>>,
71}
72
73/// Rebuilds [`LayerClips`] for this frame. Runs in `PostUpdate` after
74/// [`super::sync_layer_geometry`] (fresh [`LayerMembership`]) and after
75/// `bevy_ui`'s `UiSystems::PostLayout` (final [`CalculatedClip`] values for
76/// the top-level quad clips). Must NOT feed [`super::resolve_layer_repaints`]:
77/// clip changes never dirty a capture — that is the point.
78#[allow(clippy::type_complexity)]
79pub fn sync_layer_clips(
80    roots: Query<(Entity, Option<&CalculatedClip>), With<PromotedLayer>>,
81    root_markers: Query<(), With<PromotedLayer>>,
82    children: Query<&Children>,
83    nodes: Query<(&Node, &ComputedNode, &UiGlobalTransform, Has<OverrideClip>)>,
84    membership: Res<LayerMembership>,
85    mut clips: ResMut<LayerClips>,
86) {
87    let clips = &mut *clips;
88    clips.interior.clear();
89    clips.quads.clear();
90    for (root, calculated_clip) in &roots {
91        // Only ACTIVE roots (present in this frame's membership as their own
92        // layer) ran `mark_subtree`; inactive ones (zero-sized, not laid out)
93        // have no capture to clip and may carry stale components.
94        if membership.node_to_layer.get(&root) != Some(&root) {
95            continue;
96        }
97        // Interior cascade: restart with NO inherited clip at the root —
98        // exactly `bevy_ui`'s `update_clipping`, minus everything above.
99        cascade(root, root, None, &children, &root_markers, &nodes, clips);
100        // A top-level root's quad composites into the screen phase and clamps
101        // to the root's real inherited clip. (Nested roots' quad clips were
102        // recorded by their enclosing root's cascade at the prune point.)
103        if membership.enclosing.get(&root) == Some(&None) {
104            clips.quads.insert(root, calculated_clip.map(|c| c.clip));
105        }
106    }
107}
108
109/// One step of the interior clip cascade — mirrors `bevy_ui`'s
110/// `update_clipping` recursion (`bevy_ui-0.19.0/src/update.rs`): OverrideClip
111/// resets the inherited clip, `Display::None` forces an empty one, the node
112/// records its *inherited* value, and its own overflow only affects its
113/// children via [`children_clip`]. A nested promoted root is pruned: the
114/// cascade value at the prune point is its composite quad's clip (its quad
115/// draws inside this capture), and its interior belongs to its own cascade.
116#[allow(clippy::type_complexity)]
117fn cascade(
118    node: Entity,
119    dfs_root: Entity,
120    mut inherited: Option<Rect>,
121    children: &Query<&Children>,
122    root_markers: &Query<(), With<PromotedLayer>>,
123    nodes: &Query<(&Node, &ComputedNode, &UiGlobalTransform, Has<OverrideClip>)>,
124    clips: &mut LayerClips,
125) {
126    let Ok((ui_node, computed, transform, has_override)) = nodes.get(node) else {
127        return; // Non-UI entity: nothing to clip, nothing extracted below it.
128    };
129    if has_override {
130        inherited = None;
131    }
132    if ui_node.display == bevy::ui::Display::None {
133        inherited = Some(Rect::default());
134    }
135    if node != dfs_root && root_markers.contains(node) {
136        clips.quads.insert(node, inherited);
137        return; // The nested root's own cascade owns its interior.
138    }
139    clips.interior.insert(node, inherited);
140    let child_clip = children_clip(ui_node, computed, transform, inherited);
141    if let Ok(kids) = children.get(node) {
142        for &kid in kids {
143            cascade(
144                kid,
145                dfs_root,
146                child_clip,
147                children,
148                root_markers,
149                nodes,
150                clips,
151            );
152        }
153    }
154}
155
156/// The clip a node passes to its children — stock semantics: `Visible`
157/// overflow passes the inherited clip through; otherwise the node's
158/// [`ComputedNode::resolve_clip_rect`] (object-centered, physical px) is
159/// translated into screen space and intersected with the inherited clip.
160fn children_clip(
161    node: &Node,
162    computed: &ComputedNode,
163    transform: &UiGlobalTransform,
164    inherited: Option<Rect>,
165) -> Option<Rect> {
166    if node.overflow.is_visible() {
167        return inherited;
168    }
169    let mut clip_rect = computed.resolve_clip_rect(node.overflow, node.overflow_clip_margin);
170    clip_rect.min += transform.translation;
171    clip_rect.max += transform.translation;
172    Some(inherited.map_or(clip_rect, |c| c.intersect(clip_rect)))
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::layer::{
179        LayerContentDirt, LayerGroupAlpha, LayerRepaintState, LayersRegistry, PromotionReasons,
180        resolve_layer_repaints, sync_layer_geometry,
181    };
182    use crate::protocol::NodeId;
183    use bevy::math::Affine2;
184    use bevy::ui::Overflow;
185
186    /// World + schedule harness like `layer::tests::geometry_world`, extended
187    /// with [`sync_layer_clips`] between geometry and repaint resolution —
188    /// the production ordering.
189    fn clip_world() -> (World, Schedule) {
190        let mut world = World::new();
191        world.init_resource::<LayerMembership>();
192        world.init_resource::<LayersRegistry>();
193        world.init_resource::<LayerRepaintState>();
194        world.init_resource::<LayerContentDirt>();
195        world.init_resource::<LayerClips>();
196        let mut schedule = Schedule::default();
197        schedule.add_systems(
198            (
199                sync_layer_geometry,
200                sync_layer_clips,
201                resolve_layer_repaints,
202            )
203                .chain(),
204        );
205        (world, schedule)
206    }
207
208    /// A laid-out plain UI node: `center` is the `UiGlobalTransform`
209    /// translation (node center), so the border box is `center ± size/2`.
210    fn spawn_node(world: &mut World, size: Vec2, center: Vec2, overflow: Overflow) -> Entity {
211        world
212            .spawn((
213                Node {
214                    overflow,
215                    ..Default::default()
216                },
217                ComputedNode {
218                    size,
219                    ..Default::default()
220                },
221                UiGlobalTransform::from(Affine2::from_translation(center)),
222            ))
223            .id()
224    }
225
226    /// A promoted layer root (adds the marker components
227    /// [`sync_layer_geometry`] requires on top of [`spawn_node`]).
228    fn spawn_root(
229        world: &mut World,
230        id: NodeId,
231        size: Vec2,
232        center: Vec2,
233        overflow: Overflow,
234    ) -> Entity {
235        let e = spawn_node(world, size, center, overflow);
236        world.entity_mut(e).insert((
237            crate::bridge::RNode(id),
238            LayerGroupAlpha(1.0),
239            PromotedLayer {
240                reasons: PromotionReasons(PromotionReasons::FILTER),
241            },
242        ));
243        e
244    }
245
246    fn interior(world: &World, e: Entity) -> Option<Rect> {
247        *world
248            .resource::<LayerClips>()
249            .interior
250            .get(&e)
251            .unwrap_or_else(|| panic!("no interior clip entry for {e:?}"))
252    }
253
254    fn quad(world: &World, e: Entity) -> Option<Rect> {
255        *world
256            .resource::<LayerClips>()
257            .quads
258            .get(&e)
259            .unwrap_or_else(|| panic!("no quad clip entry for {e:?}"))
260    }
261
262    /// Ancestor clipping (hand-inserted `CalculatedClip`, as real `bevy_ui`
263    /// would under a scrollport) is STRIPPED from every member's interior
264    /// clip — and becomes the top-level root's quad clip instead. A root
265    /// without any inherited clip gets an unclipped quad.
266    #[test]
267    fn ancestor_clip_stripped_and_moved_to_quad() {
268        let (mut world, mut schedule) = clip_world();
269        let clip = Rect::new(0.0, 0.0, 30.0, 30.0);
270        let root = spawn_root(
271            &mut world,
272            1,
273            Vec2::new(100.0, 60.0),
274            Vec2::new(50.0, 30.0),
275            Overflow::visible(),
276        );
277        let child = spawn_node(
278            &mut world,
279            Vec2::new(40.0, 20.0),
280            Vec2::new(50.0, 30.0),
281            Overflow::visible(),
282        );
283        world.entity_mut(child).insert(ChildOf(root));
284        world.entity_mut(root).insert(CalculatedClip { clip });
285        world.entity_mut(child).insert(CalculatedClip { clip });
286        let unclipped = spawn_root(
287            &mut world,
288            2,
289            Vec2::new(50.0, 50.0),
290            Vec2::new(300.0, 300.0),
291            Overflow::visible(),
292        );
293        schedule.run(&mut world);
294
295        assert_eq!(interior(&world, root), None);
296        assert_eq!(interior(&world, child), None);
297        assert_eq!(quad(&world, root), Some(clip));
298        assert_eq!(quad(&world, unclipped), None);
299    }
300
301    /// The root's own overflow clips its CHILDREN only (stock semantics):
302    /// the root's paint is unclipped in-capture, children clamp to the root's
303    /// `resolve_clip_rect`, and deeper interior clippers intersect.
304    #[test]
305    fn root_overflow_clips_children_only() {
306        let (mut world, mut schedule) = clip_world();
307        let root = spawn_root(
308            &mut world,
309            1,
310            Vec2::splat(200.0),
311            Vec2::splat(100.0),
312            Overflow::clip(),
313        );
314        // Offset so the container's clip rect escapes the root's on the right.
315        let container = spawn_node(
316            &mut world,
317            Vec2::splat(100.0),
318            Vec2::new(160.0, 100.0),
319            Overflow::clip(),
320        );
321        world.entity_mut(container).insert(ChildOf(root));
322        let grandchild = spawn_node(
323            &mut world,
324            Vec2::splat(40.0),
325            Vec2::new(160.0, 100.0),
326            Overflow::visible(),
327        );
328        world.entity_mut(grandchild).insert(ChildOf(container));
329        schedule.run(&mut world);
330
331        let root_rect = Rect::new(0.0, 0.0, 200.0, 200.0);
332        let container_rect = Rect::new(110.0, 50.0, 210.0, 150.0);
333        assert_eq!(interior(&world, root), None, "root paint unclipped");
334        assert_eq!(interior(&world, container), Some(root_rect));
335        assert_eq!(
336            interior(&world, grandchild),
337            Some(root_rect.intersect(container_rect)),
338            "interior clippers intersect"
339        );
340    }
341
342    /// A nested promoted root is PRUNED from the enclosing DFS: the cascade
343    /// value at the prune point becomes its quad clip, and its own interior
344    /// cascade restarts from nothing — outer clips must not leak in.
345    #[test]
346    fn nested_roots_get_quad_clips_and_restart_interior() {
347        let (mut world, mut schedule) = clip_world();
348        let outer = spawn_root(
349            &mut world,
350            1,
351            Vec2::splat(300.0),
352            Vec2::splat(150.0),
353            Overflow::visible(),
354        );
355        let clipper = spawn_node(
356            &mut world,
357            Vec2::splat(100.0),
358            Vec2::splat(100.0),
359            Overflow::clip(),
360        );
361        world.entity_mut(clipper).insert(ChildOf(outer));
362        let inner = spawn_root(
363            &mut world,
364            2,
365            Vec2::splat(80.0),
366            Vec2::splat(100.0),
367            Overflow::visible(),
368        );
369        world.entity_mut(inner).insert(ChildOf(clipper));
370        let inner_child = spawn_node(
371            &mut world,
372            Vec2::splat(40.0),
373            Vec2::splat(100.0),
374            Overflow::visible(),
375        );
376        world.entity_mut(inner_child).insert(ChildOf(inner));
377        // Deeper: a clipper partially OUTSIDE the outer clipper's rect, then
378        // a third root — its quad clip must be the inner cascade value alone
379        // (restart proof: no intersection with the outer clipper's rect).
380        let clipper2 = spawn_node(
381            &mut world,
382            Vec2::splat(60.0),
383            Vec2::new(140.0, 100.0),
384            Overflow::clip(),
385        );
386        world.entity_mut(clipper2).insert(ChildOf(inner));
387        let innermost = spawn_root(
388            &mut world,
389            3,
390            Vec2::splat(40.0),
391            Vec2::new(140.0, 100.0),
392            Overflow::visible(),
393        );
394        world.entity_mut(innermost).insert(ChildOf(clipper2));
395        schedule.run(&mut world);
396
397        let clipper_rect = Rect::new(50.0, 50.0, 150.0, 150.0);
398        let clipper2_rect = Rect::new(110.0, 70.0, 170.0, 130.0);
399        assert_eq!(quad(&world, inner), Some(clipper_rect));
400        assert_eq!(interior(&world, inner), None, "interior restarts");
401        assert_eq!(interior(&world, inner_child), None);
402        assert_eq!(
403            quad(&world, innermost),
404            Some(clipper2_rect),
405            "inner cascade only — outer clipper must not leak through the restart"
406        );
407        assert_eq!(interior(&world, clipper), None, "outer member");
408    }
409
410    /// `OverrideClip` resets the inherited interior clip; `Display::None`
411    /// forces an empty clip down its subtree (stock `update_clipping` order).
412    #[test]
413    fn override_clip_and_display_none() {
414        let (mut world, mut schedule) = clip_world();
415        let root = spawn_root(
416            &mut world,
417            1,
418            Vec2::splat(200.0),
419            Vec2::splat(100.0),
420            Overflow::clip(),
421        );
422        let root_rect = Rect::new(0.0, 0.0, 200.0, 200.0);
423
424        let escapee = spawn_node(
425            &mut world,
426            Vec2::splat(40.0),
427            Vec2::splat(100.0),
428            Overflow::visible(),
429        );
430        world
431            .entity_mut(escapee)
432            .insert((ChildOf(root), OverrideClip));
433        let escapee_child = spawn_node(
434            &mut world,
435            Vec2::splat(20.0),
436            Vec2::splat(100.0),
437            Overflow::visible(),
438        );
439        world.entity_mut(escapee_child).insert(ChildOf(escapee));
440
441        let hidden = spawn_node(
442            &mut world,
443            Vec2::splat(40.0),
444            Vec2::splat(100.0),
445            Overflow::visible(),
446        );
447        world.entity_mut(hidden).insert(ChildOf(root));
448        world.entity_mut(hidden).get_mut::<Node>().unwrap().display = bevy::ui::Display::None;
449        let hidden_child = spawn_node(
450            &mut world,
451            Vec2::splat(20.0),
452            Vec2::splat(100.0),
453            Overflow::visible(),
454        );
455        world.entity_mut(hidden_child).insert(ChildOf(hidden));
456
457        let plain = spawn_node(
458            &mut world,
459            Vec2::splat(40.0),
460            Vec2::splat(100.0),
461            Overflow::visible(),
462        );
463        world.entity_mut(plain).insert(ChildOf(root));
464        schedule.run(&mut world);
465
466        assert_eq!(interior(&world, escapee), None);
467        assert_eq!(interior(&world, escapee_child), None);
468        assert_eq!(interior(&world, hidden), Some(Rect::default()));
469        assert_eq!(interior(&world, hidden_child), Some(Rect::default()));
470        assert_eq!(interior(&world, plain), Some(root_rect));
471    }
472
473    /// THE regression the whole change exists for: scrolling (uniform
474    /// translation of the subtree under a fixed ancestor clip) must keep the
475    /// capture cache clean — clips never feed the repaint resolver — while
476    /// the quad clip stays live; and a clip-rect change alone must not dirty
477    /// either.
478    #[test]
479    fn scroll_holds_cache_and_updates_quad_clip() {
480        let (mut world, mut schedule) = clip_world();
481        let clip = Rect::new(0.0, 0.0, 200.0, 100.0);
482        // Content sits below a 100-tall scrollport (fully clipped away).
483        let root = spawn_root(
484            &mut world,
485            1,
486            Vec2::new(100.0, 60.0),
487            Vec2::new(50.0, 130.0),
488            Overflow::visible(),
489        );
490        let child = spawn_node(
491            &mut world,
492            Vec2::new(40.0, 20.0),
493            Vec2::new(50.0, 130.0),
494            Overflow::visible(),
495        );
496        world.entity_mut(child).insert(ChildOf(root));
497        world.entity_mut(root).insert(CalculatedClip { clip });
498        world.entity_mut(child).insert(CalculatedClip { clip });
499        schedule.run(&mut world);
500        schedule.run(&mut world); // settle: steady state is clean
501        assert!(world.resource::<LayerRepaintState>().dirty.is_empty());
502        assert_eq!(quad(&world, root), Some(clip));
503
504        // Scroll into view: the whole subtree translates; the clip stays.
505        let delta = Vec2::new(0.0, -100.0);
506        for e in [root, child] {
507            let center = world.get::<UiGlobalTransform>(e).unwrap().translation + delta;
508            *world.get_mut::<UiGlobalTransform>(e).unwrap() =
509                UiGlobalTransform::from(Affine2::from_translation(center));
510        }
511        schedule.run(&mut world);
512        assert!(
513            world.resource::<LayerRepaintState>().dirty.is_empty(),
514            "scrolling must not re-capture"
515        );
516        assert_eq!(quad(&world, root), Some(clip));
517
518        // The scrollport itself changes: quad clip follows, still no dirt.
519        let grown = Rect::new(0.0, 0.0, 200.0, 150.0);
520        world.get_mut::<CalculatedClip>(root).unwrap().clip = grown;
521        schedule.run(&mut world);
522        assert!(
523            world.resource::<LayerRepaintState>().dirty.is_empty(),
524            "a clip change must not re-capture"
525        );
526        assert_eq!(quad(&world, root), Some(grown));
527    }
528}