Skip to main content

telar_ui_core/
overlay.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3
4use geometry_core::Rect;
5use layout_core::{LayoutError, LayoutStyle, NodeId};
6use platform_core::Event;
7use reactive_core::RwSignal;
8use ui_tree::{
9    Component, EventResult, OverlaySink, RenderNode, register_overlay, unregister_overlay,
10};
11
12use crate::context::{attach_overlay, detach_overlay, remove_node};
13use crate::layout_item::{LayoutItem, TrackedChildren, register_container};
14use crate::pointer::{dispatch_container_event, offset_pointer};
15use crate::scroll_region::visible_rect;
16
17/// Where an anchored overlay's content sits relative to its trigger widget. Maps to the `.rsx` `placement`
18/// attribute. Only vertical placements are provided today; horizontal ones would follow the same pattern.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum Placement {
21    /// Content's top-left at the trigger's bottom-left — a menu dropping down from its button.
22    Below,
23    /// Content's bottom-left at the trigger's top-left — a menu opening upward.
24    Above,
25}
26
27/// The world-vs-local anchor fallback shared by the anchored menu/select/tooltip panels.
28///
29/// Uses the trigger's *on-screen* rect, not its laid-out one: a trigger inside a scrolled viewport is drawn
30/// somewhere other than where it was laid out, and a panel placed at the laid-out spot lands off by the
31/// scroll offset.
32pub fn anchor_rect(node: NodeId, fallback: &RwSignal<Rect>) -> Rect {
33    visible_rect(node).unwrap_or_else(|| fallback.peek())
34}
35
36/// Anchors an overlay's content to a trigger widget. `trigger` is the trigger's laid-out rect (what
37/// `track_layout` returns); reading it in `view()` makes the content follow the trigger across relayouts.
38#[derive(Clone)]
39struct Anchor {
40    trigger: RwSignal<Rect>,
41    placement: Placement,
42}
43
44/// The panel box: the union of the children's laid-out rects (their intrinsic size before anchoring). `read`
45/// is `peek` during event routing (untracked) and `get` inside `view()` (so the render follows layout).
46fn panel_rect(children: &TrackedChildren, read: impl Fn(&RwSignal<Rect>) -> Rect) -> Rect {
47    let mut acc: Option<Rect> = None;
48    for child in children {
49        if let Some(sig) = &child.rect {
50            let r = read(sig);
51            acc = Some(acc.map_or(r, |u| u.union(r)));
52        }
53    }
54    acc.unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0))
55}
56
57/// The translate that moves `panel` from where it was laid out (near the host origin) to its anchored spot
58/// next to `trigger`. Placement picks the target top-left; the offset is that target minus the panel origin.
59/// On-screen clamping is left to the caller for now (it needs the viewport size, which is not plumbed here).
60fn anchor_translate(trigger: Rect, panel: Rect, placement: Placement) -> (f32, f32) {
61    let (target_x, target_y) = match placement {
62        Placement::Below => (trigger.x, trigger.y + trigger.height),
63        Placement::Above => (trigger.x, trigger.y - panel.height),
64    };
65    (target_x - panel.x, target_y - panel.y)
66}
67
68/// The content rect an anchored overlay actually occupies on screen: its panel translated to the trigger.
69/// This is the hit-test barrier the registry sees, so only the visible panel blocks — clicks elsewhere fall
70/// through even though the underlying content node fills the viewport.
71fn anchored_content_rect(
72    children: &TrackedChildren,
73    anchor: &Anchor,
74    read: impl Fn(&RwSignal<Rect>) -> Rect,
75) -> Rect {
76    let panel = panel_rect(children, &read);
77    let (dx, dy) = anchor_translate(read(&anchor.trigger), panel, anchor.placement);
78    Rect::new(panel.x + dx, panel.y + dy, panel.width, panel.height)
79}
80
81/// The overlay's hook into priority pointer routing. Shares the same `Rc<RefCell>` child handles as the
82/// `Overlay` widget (`Child` is a cheap clonable handle), so a pointer event dispatched through the sink
83/// reaches the very same content the widget renders. `content_rect` is the content container's layout rect,
84/// used as the hit-test barrier (a full-viewport scrim blocks everything; an anchored panel only itself).
85struct OverlaySinkImpl {
86    content_rect: RwSignal<Rect>,
87    children: RefCell<TrackedChildren>,
88    // Modal (swallow every event over the barrier) vs click-through (only where a child handled it).
89    blocking: bool,
90    // When set, the barrier and dispatch coordinates track the trigger instead of the fill container.
91    anchor: Option<Anchor>,
92    // A kept-mounted overlay whose `visible` reads false is inert: an empty barrier so it blocks nothing.
93    visible: Rc<dyn Fn() -> bool>,
94}
95
96impl OverlaySink for OverlaySinkImpl {
97    fn content_rect(&self) -> Rect {
98        // Hidden (kept mounted for a modal that toggles visibility): report an empty barrier so no pointer
99        // event routes to it and nothing behind is blocked.
100        if !(self.visible)() {
101            return Rect::default();
102        }
103        // peek, not get: routing runs during (batched) event dispatch, not inside a tracking effect.
104        match &self.anchor {
105            None => self.content_rect.peek(),
106            Some(anchor) => anchored_content_rect(&self.children.borrow(), anchor, |s| s.peek()),
107        }
108    }
109
110    fn dispatch(&self, event: &Event) -> EventResult {
111        // Anchored content is laid out at its intrinsic (un-anchored) origin but hit at the anchored spot,
112        // so map the world event back into the children's local space by the inverse translate first.
113        let offset = self.anchor.as_ref().map(|anchor| {
114            let panel = panel_rect(&self.children.borrow(), |s| s.peek());
115            anchor_translate(anchor.trigger.peek(), panel, anchor.placement)
116        });
117        match offset {
118            Some((dx, dy)) => {
119                // Map world → children-local space: local = world − translate. `offset_pointer(dx,dy)`
120                // applies the inverse of translate(dx,dy), i.e. subtracts it — so the sign is POSITIVE
121                // (matches scroll_area's use). Negating it double-adds the anchor offset and mishits rows.
122                let local = offset_pointer(event, dx as f64, dy as f64);
123                let event = local.as_ref().unwrap_or(event);
124                dispatch_container_event(&mut self.children.borrow_mut(), event)
125            }
126            None => dispatch_container_event(&mut self.children.borrow_mut(), event),
127        }
128    }
129
130    fn blocking(&self) -> bool {
131        self.blocking
132    }
133}
134
135/// A portal layer: its content is laid out out-of-flow, filling the viewport, and hoisted to the top at
136/// compose time — drawn above everything and free of any ancestor clip/transform. A base primitive:
137/// unstyled; wrap content in a `box` for a scrim/panel, and position it with normal flex (`align`/`justify`).
138///
139/// The content is a separate layout node **attached to the layout root** (the overlay host), not to the
140/// widget's DOM parent — so a portal declared deep in the tree (e.g. inside a reactive `if`) still covers
141/// the whole window instead of collapsing to its parent's box. The widget hands its DOM parent only a
142/// zero-size placeholder, so it never affects sibling layout. If no host has been laid out yet (a portal
143/// present at the very first frame), it falls back to laying the content out in place.
144///
145/// Positioned pointer events reach the content with priority via a thread-local overlay registry (see
146/// `ui_tree::overlay_dispatch`): a click on the overlay is routed here before the main tree walk and does
147/// not fall through to the content behind it, so a scrim that fills the viewport reads as a modal.
148///
149/// Variants (all portal the same way, they differ in how they route clicks and where the content sits):
150/// - [`Overlay::new`] — modal: blocks every click inside its content rect (a full-viewport scrim).
151/// - [`Overlay::new_click_through`] — non-modal: clicks on the transparent fill fall through to the tree;
152///   only clicks a child handles are consumed. For a toast/tooltip layer that must not eat background clicks.
153/// - [`Overlay::anchored`] — positions the content next to a trigger widget (dropdowns/menus/tooltips): the
154///   content is translated to the trigger's rect and only that panel blocks, so clicks elsewhere fall through.
155pub struct Overlay {
156    // Node handed to the DOM parent: a 0×0 placeholder (when portaled) or the content itself (fallback).
157    layout_node: NodeId,
158    // The viewport-filling content node; `Some` and attached to the host only when portaled.
159    portaled_content: Option<NodeId>,
160    children: TrackedChildren,
161    // Registry id for priority pointer routing; removed on drop.
162    overlay_id: u64,
163    // Set for `anchored`: translates the rendered content to the trigger's rect (see `view`).
164    anchor: Option<Anchor>,
165    // Read each `view()`: when false the overlay draws nothing (kept mounted so its content — e.g. a modal's
166    // slotted body — survives a close/reopen instead of being rebuilt from a consumed slot).
167    visible: Rc<dyn Fn() -> bool>,
168}
169
170impl Overlay {
171    /// A modal portal: the content fills the viewport and blocks every click behind it.
172    pub fn new(
173        layout_style: LayoutStyle,
174        children: Vec<Box<dyn LayoutItem>>,
175    ) -> Result<Self, LayoutError> {
176        Self::build(layout_style, children, true, None, Rc::new(|| true))
177    }
178
179    /// A modal portal that is kept mounted and shown/hidden by `visible` (read each frame). Unlike disposing
180    /// and rebuilding the overlay on every open, this preserves its content across close/reopen — needed for a
181    /// dialog whose body arrives as a pre-built slot (which cannot be rebuilt once consumed). Hidden, it draws
182    /// nothing and blocks nothing.
183    pub fn toggleable(
184        layout_style: LayoutStyle,
185        children: Vec<Box<dyn LayoutItem>>,
186        visible: impl Fn() -> bool + 'static,
187    ) -> Result<Self, LayoutError> {
188        Self::build(layout_style, children, true, None, Rc::new(visible))
189    }
190
191    /// A non-modal portal: clicks on the transparent fill fall through to the content behind; only clicks a
192    /// child actually handles are consumed. Use for a toast/tooltip layer that must not block the page.
193    pub fn new_click_through(
194        layout_style: LayoutStyle,
195        children: Vec<Box<dyn LayoutItem>>,
196    ) -> Result<Self, LayoutError> {
197        Self::build(layout_style, children, false, None, Rc::new(|| true))
198    }
199
200    /// A portal whose content is positioned next to `trigger` (a dropdown/menu/tooltip popping up by its
201    /// button). The content sizes to its intrinsic panel and is translated to the trigger's rect per
202    /// `placement`; only that panel blocks (the barrier tracks the trigger), so clicks elsewhere fall through.
203    pub fn anchored(
204        layout_style: LayoutStyle,
205        children: Vec<Box<dyn LayoutItem>>,
206        trigger: RwSignal<Rect>,
207        placement: Placement,
208    ) -> Result<Self, LayoutError> {
209        Self::build(
210            layout_style,
211            children,
212            true,
213            Some(Anchor { trigger, placement }),
214            Rc::new(|| true),
215        )
216    }
217
218    fn build(
219        layout_style: LayoutStyle,
220        children: Vec<Box<dyn LayoutItem>>,
221        blocking: bool,
222        anchor: Option<Anchor>,
223        visible: Rc<dyn Fn() -> bool>,
224    ) -> Result<Self, LayoutError> {
225        // `absolute_fill` takes the layer out of flow and sizes it to its container; attaching it to the
226        // host makes that container the viewport. The caller's flex alignment positions content inside; an
227        // anchored overlay instead lets its content size intrinsically and moves it with a transform.
228        let (content, content_rect, children) =
229            register_container(layout_style.absolute_fill(), children)?;
230
231        // Register for priority pointer routing. The sink shares the same child handles as the widget.
232        let sink: Rc<dyn OverlaySink> = Rc::new(OverlaySinkImpl {
233            content_rect,
234            children: RefCell::new(children.clone()),
235            blocking,
236            anchor: anchor.clone(),
237            visible: visible.clone(),
238        });
239        let overlay_id = register_overlay(sink);
240
241        if attach_overlay(content) {
242            // Portaled: the DOM parent gets a 0×0 placeholder so the portal takes no space in the flow.
243            let (placeholder, _r) =
244                crate::context::new_leaf(LayoutStyle::new().width(0.0).height(0.0))?;
245            Ok(Overlay {
246                layout_node: placeholder,
247                portaled_content: Some(content),
248                children,
249                overlay_id,
250                anchor,
251                visible,
252            })
253        } else {
254            // No host yet: lay the content out in place (it will cover its parent, not the viewport).
255            Ok(Overlay {
256                layout_node: content,
257                portaled_content: None,
258                children,
259                overlay_id,
260                anchor,
261                visible,
262            })
263        }
264    }
265}
266
267impl LayoutItem for Overlay {
268    fn layout_node(&self) -> NodeId {
269        self.layout_node
270    }
271}
272
273impl Component for Overlay {
274    fn view(&self) -> RenderNode {
275        // Kept mounted but hidden: draw nothing (its content stays alive for the next time it is shown).
276        if !(self.visible)() {
277            return RenderNode::Empty;
278        }
279        let boundaries = self.children.iter().map(|c| c.segment.boundary());
280        match &self.anchor {
281            None => RenderNode::overlay(boundaries),
282            Some(anchor) => {
283                // `get` (not peek) so the transform re-runs when the trigger or the panel's size changes.
284                let panel = panel_rect(&self.children, |s| s.get());
285                let (dx, dy) = anchor_translate(anchor.trigger.get(), panel, anchor.placement);
286                // Translate matrix `[1,0,0,1,dx,dy]`: the content is laid out at the origin, drawn at the trigger.
287                RenderNode::overlay([RenderNode::transform_with(
288                    [1.0, 0.0, 0.0, 1.0, dx, dy],
289                    boundaries,
290                )])
291            }
292        }
293    }
294
295    fn on_event(&mut self, event: &Event) -> EventResult {
296        // Positioned pointer events are delivered with priority through the overlay registry (before this
297        // in-tree walk reaches us); dispatching them here too would double-fire. Non-positioned events
298        // (keyboard shortcuts, CursorLeft) still flow through the tree, so forward those to the content.
299        if matches!(
300            event,
301            Event::PointerPressed { .. }
302                | Event::PointerMoved { .. }
303                | Event::PointerReleased { .. }
304        ) {
305            return EventResult::Ignored;
306        }
307        dispatch_container_event(&mut self.children, event)
308    }
309
310    fn debug_name(&self) -> &'static str {
311        "Overlay"
312    }
313}
314
315impl Drop for Overlay {
316    fn drop(&mut self) {
317        unregister_overlay(self.overlay_id);
318        // Detach the portaled content from the host and free it when the overlay is disposed (e.g. a
319        // reactive `if` hiding a modal) — it lives outside the DOM subtree, so nothing else removes it.
320        if let Some(content) = self.portaled_content {
321            detach_overlay(content);
322            remove_node(content);
323        }
324    }
325}
326
327#[cfg(test)]
328impl Overlay {
329    // The on-screen content rect (the hit-test barrier the registry sees) for an anchored overlay.
330    fn anchored_barrier(&self) -> Rect {
331        let anchor = self.anchor.as_ref().expect("overlay is not anchored");
332        anchored_content_rect(&self.children, anchor, |s| s.peek())
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use crate::context::reset_layout_runtime;
339    use layout_core::AvailableSpace;
340    use platform_core::{PointerButton, PointerSource};
341    use reactive_core::{RwSignal, signal};
342
343    use super::*;
344    use crate::ComponentList;
345    use crate::container::Container;
346    use crate::context::compute_layout;
347
348    fn press(x: f64, y: f64) -> Event {
349        Event::PointerPressed {
350            x,
351            y,
352            button: PointerButton::Primary,
353            source: PointerSource::Mouse,
354        }
355    }
356    fn release(x: f64, y: f64) -> Event {
357        Event::PointerReleased {
358            x,
359            y,
360            button: PointerButton::Primary,
361            source: PointerSource::Mouse,
362        }
363    }
364
365    // Mirror the runner: consult the overlay registry first, then walk the tree only if no overlay
366    // consumed the event. (Production does this in `handler.rs` via the `App::dispatch_overlays` bridge.)
367    fn route(tree: &mut ComponentList, event: &Event) {
368        if crate::dispatch_overlays(event) == EventResult::Ignored {
369            tree.on_event(event);
370        }
371    }
372
373    // A container filling 400×400 whose on_press flips `flag`, used as both the modal scrim and the
374    // background it covers.
375    fn pressable(flag: RwSignal<bool>) -> Container {
376        Container::new(LayoutStyle::new().width(400.0).height(400.0), vec![])
377            .unwrap()
378            .on_press(move || flag.set(true))
379    }
380
381    // Baseline (guards the assertion below from being vacuous): with no overlay, a tap on the background
382    // fires its on_press.
383    #[test]
384    fn background_alone_receives_tap() {
385        reset_layout_runtime();
386        let clicked = signal(false);
387        let bg = pressable(clicked.clone());
388        let root = Container::new(
389            LayoutStyle::new().flex_column().width(400.0).height(400.0),
390            vec![Box::new(bg)],
391        )
392        .unwrap();
393        let root_node = root.layout_node();
394        compute_layout(
395            root_node,
396            AvailableSpace::Definite(400.0),
397            AvailableSpace::Definite(400.0),
398        )
399        .unwrap();
400        let mut tree = ComponentList::new(root);
401        let _ = tree.commands();
402
403        route(&mut tree, &press(200.0, 200.0));
404        route(&mut tree, &release(200.0, 200.0));
405        assert!(
406            clicked.get(),
407            "background on_press must fire without an overlay"
408        );
409    }
410
411    // The fix: an overlay is hit-tested before the tree, so a tap over it reaches the overlay's content
412    // (the scrim) and is blocked from the background it covers.
413    #[test]
414    fn overlay_receives_tap_and_blocks_background() {
415        reset_layout_runtime();
416        let bg_clicked = signal(false);
417        let overlay_clicked = signal(false);
418
419        let bg = pressable(bg_clicked.clone());
420        // The scrim fills the overlay (which `absolute_fill`s the root), so it covers the background.
421        let scrim = Container::new(LayoutStyle::new().width(400.0).height(400.0), vec![])
422            .unwrap()
423            .on_press({
424                let s = overlay_clicked.clone();
425                move || s.set(true)
426            });
427        let overlay = Overlay::new(LayoutStyle::new(), vec![Box::new(scrim)]).unwrap();
428        let root = Container::new(
429            LayoutStyle::new().flex_column().width(400.0).height(400.0),
430            vec![Box::new(bg), Box::new(overlay)],
431        )
432        .unwrap();
433        let root_node = root.layout_node();
434        compute_layout(
435            root_node,
436            AvailableSpace::Definite(400.0),
437            AvailableSpace::Definite(400.0),
438        )
439        .unwrap();
440        let mut tree = ComponentList::new(root);
441        let _ = tree.commands();
442
443        // A tap at the center hits both the background and the overlay; the overlay must win.
444        route(&mut tree, &press(200.0, 200.0));
445        route(&mut tree, &release(200.0, 200.0));
446
447        assert!(
448            overlay_clicked.get(),
449            "the tap must reach the overlay content"
450        );
451        assert!(
452            !bg_clicked.get(),
453            "the overlay must block the tap from the content behind it"
454        );
455    }
456
457    // The real modal scenario: the page is laid out first (registering the overlay host), THEN the modal
458    // opens and portals its content to the host (attach_overlay succeeds). This exercises the portaled
459    // path — where `content_rect` is driven to the viewport by a later relayout — not the in-place
460    // fallback the test above hits (overlay built before any layout host exists).
461    #[test]
462    fn portaled_overlay_blocks_background() {
463        use crate::context::relayout_if_dirty;
464
465        reset_layout_runtime();
466        let bg_clicked = signal(false);
467
468        // 1. Lay out the page first: this registers `root` as the overlay host.
469        let bg = pressable(bg_clicked.clone());
470        let root = Container::new(
471            LayoutStyle::new().flex_column().width(400.0).height(400.0),
472            vec![Box::new(bg)],
473        )
474        .unwrap();
475        let root_node = root.layout_node();
476        compute_layout(
477            root_node,
478            AvailableSpace::Definite(400.0),
479            AvailableSpace::Definite(400.0),
480        )
481        .unwrap();
482        let mut tree = ComponentList::new(root);
483        let _ = tree.commands();
484
485        // 2. Now open the modal: its content portals to the host and fills the viewport after relayout.
486        let overlay_clicked = signal(false);
487        let scrim = Container::new(LayoutStyle::new().width(400.0).height(400.0), vec![])
488            .unwrap()
489            .on_press({
490                let s = overlay_clicked.clone();
491                move || s.set(true)
492            });
493        let _overlay = Overlay::new(LayoutStyle::new(), vec![Box::new(scrim)]).unwrap();
494        relayout_if_dirty();
495
496        // 3. A tap at the center must reach the portaled overlay and be blocked from the page behind it.
497        route(&mut tree, &press(200.0, 200.0));
498        route(&mut tree, &release(200.0, 200.0));
499
500        assert!(
501            overlay_clicked.get(),
502            "the tap must reach the portaled overlay content"
503        );
504        assert!(
505            !bg_clicked.get(),
506            "the portaled overlay must block the tap from the page behind it"
507        );
508    }
509
510    // Deliverable 1 at the widget level: a click-through overlay with a small panel lets a tap on its
511    // transparent area reach the background, but still consumes a tap that lands on the panel.
512    #[test]
513    fn click_through_overlay_lets_background_tap_through() {
514        reset_layout_runtime();
515        let bg_clicked = signal(false);
516        let panel_clicked = signal(false);
517
518        let bg = pressable(bg_clicked.clone());
519        // A 100×100 panel in the top-left corner; the rest of the click-through layer is transparent.
520        let panel = Container::new(LayoutStyle::new().width(100.0).height(100.0), vec![])
521            .unwrap()
522            .on_press({
523                let s = panel_clicked.clone();
524                move || s.set(true)
525            });
526        let overlay =
527            Overlay::new_click_through(LayoutStyle::new(), vec![Box::new(panel)]).unwrap();
528        let root = Container::new(
529            LayoutStyle::new().flex_column().width(400.0).height(400.0),
530            vec![Box::new(bg), Box::new(overlay)],
531        )
532        .unwrap();
533        let root_node = root.layout_node();
534        compute_layout(
535            root_node,
536            AvailableSpace::Definite(400.0),
537            AvailableSpace::Definite(400.0),
538        )
539        .unwrap();
540        let mut tree = ComponentList::new(root);
541        let _ = tree.commands();
542
543        // A tap outside the panel falls through the transparent layer to the background.
544        route(&mut tree, &press(200.0, 200.0));
545        route(&mut tree, &release(200.0, 200.0));
546        assert!(
547            bg_clicked.get(),
548            "a tap on the transparent area must reach the background"
549        );
550        assert!(
551            !panel_clicked.get(),
552            "the panel must not receive a tap outside it"
553        );
554
555        // A tap on the panel is consumed by the overlay and does not reach the background.
556        bg_clicked.set(false);
557        route(&mut tree, &press(50.0, 50.0));
558        route(&mut tree, &release(50.0, 50.0));
559        assert!(panel_clicked.get(), "a tap on the panel must reach it");
560        assert!(
561            !bg_clicked.get(),
562            "the panel must block the tap from the background"
563        );
564    }
565
566    // Deliverable 2: an anchored overlay's on-screen content rect origin tracks its trigger rect, and
567    // follows the trigger when it moves — proving the content is positioned against the trigger, not the fill.
568    #[test]
569    fn anchored_content_tracks_trigger() {
570        use crate::context::relayout_if_dirty;
571
572        reset_layout_runtime();
573
574        // 1. Lay out a page first so the overlay host exists (the anchored content portals to it).
575        let root = Container::new(
576            LayoutStyle::new().flex_column().width(400.0).height(400.0),
577            vec![],
578        )
579        .unwrap();
580        let root_node = root.layout_node();
581        compute_layout(
582            root_node,
583            AvailableSpace::Definite(400.0),
584            AvailableSpace::Definite(400.0),
585        )
586        .unwrap();
587        let tree = ComponentList::new(root);
588        let _ = tree.commands();
589
590        // 2. Open an anchored overlay below a trigger, with a fixed 120×60 panel.
591        let trigger = signal(Rect::new(50.0, 20.0, 80.0, 30.0));
592        let panel = Container::new(LayoutStyle::new().width(120.0).height(60.0), vec![]).unwrap();
593        let overlay = Overlay::anchored(
594            LayoutStyle::new(),
595            vec![Box::new(panel)],
596            trigger.clone(),
597            Placement::Below,
598        )
599        .unwrap();
600        relayout_if_dirty();
601
602        // Below: the content's top-left sits at the trigger's bottom-left (50, 20 + 30) with the panel's size.
603        let rect = overlay.anchored_barrier();
604        assert_eq!((rect.x, rect.y), (50.0, 50.0));
605        assert_eq!((rect.width, rect.height), (120.0, 60.0));
606
607        // Move the trigger; the anchored content origin follows it (no relayout needed — it is a transform).
608        trigger.set(Rect::new(200.0, 100.0, 80.0, 30.0));
609        let rect = overlay.anchored_barrier();
610        assert_eq!((rect.x, rect.y), (200.0, 130.0));
611        assert_eq!((rect.width, rect.height), (120.0, 60.0));
612    }
613}