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}