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    /// Beside the trigger on its leading side, centred on it — where a tooltip goes when the trigger sits in
26    /// a vertical rail and the room is sideways.
27    Start,
28    /// Beside the trigger on its trailing side, centred on it.
29    End,
30}
31
32/// The world-vs-local anchor fallback shared by the anchored menu/select/tooltip panels.
33///
34/// Uses the trigger's *on-screen* rect, not its laid-out one: a trigger inside a scrolled viewport is drawn
35/// somewhere other than where it was laid out, and a panel placed at the laid-out spot lands off by the
36/// scroll offset.
37pub fn anchor_rect(node: NodeId, fallback: &RwSignal<Rect>) -> Rect {
38    visible_rect(node).unwrap_or_else(|| fallback.peek())
39}
40
41/// Anchors an overlay's content to a trigger widget. `trigger` is the trigger's laid-out rect (what
42/// `track_layout` returns); reading it in `view()` makes the content follow the trigger across relayouts.
43#[derive(Clone)]
44struct Anchor {
45    trigger: RwSignal<Rect>,
46    placement: Placement,
47}
48
49/// The panel box: the union of the children's laid-out rects (their intrinsic size before anchoring). `read`
50/// is `peek` during event routing (untracked) and `get` inside `view()` (so the render follows layout).
51fn panel_rect(children: &TrackedChildren, read: impl Fn(&RwSignal<Rect>) -> Rect) -> Rect {
52    let mut acc: Option<Rect> = None;
53    for child in children {
54        if let Some(sig) = &child.rect {
55            let r = read(sig);
56            acc = Some(acc.map_or(r, |u| u.union(r)));
57        }
58    }
59    acc.unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0))
60}
61
62/// Gap kept between an anchored panel and the edge it was pushed off, so a shifted bubble does not sit flush
63/// against the window.
64const EDGE_MARGIN: f32 = 4.0;
65
66/// Gap kept between an anchored panel and the thing it is anchored to.
67///
68/// A panel flush against its trigger reads as *part of* the trigger, which is exactly what it is not: it is
69/// a separate surface that appeared because of it. The gap is what makes a menu look attached to its button
70/// rather than grown out of it — 4px is enough to separate the two surfaces and small enough that they still
71/// read as one gesture, which is why it is the offset most anchored-panel libraries settle on.
72const ANCHOR_GAP: f32 = 4.0;
73
74/// The area an anchored panel has to stay inside. Falls back to an unbounded box before the host has been
75/// laid out (the very first frame), where clamping to nothing is the same as not clamping.
76fn anchor_viewport() -> Rect {
77    crate::context::overlay_viewport().unwrap_or(Rect::new(0.0, 0.0, f32::MAX, f32::MAX))
78}
79
80/// The translate that moves `panel` from where it was laid out (near the host origin) to its anchored spot
81/// next to `trigger`. Placement picks the target top-left; the offset is that target minus the panel origin.
82///
83/// Then the panel is kept on screen, which is the half that was missing: **flip** to the other side of the
84/// trigger when the asked-for one does not fit and the opposite does, and **shift** along the trigger's edge
85/// when it overflows sideways. Without it the placement is read off the trigger alone, so a tooltip on the
86/// rightmost button of a toolbar runs past the window and the text wraps into a column — which is not a rare
87/// case but the common one. The trigger is never covered: a flip moves the panel to its other side, and a
88/// shift only slides along the edge it is already on.
89fn anchor_translate(
90    trigger: Rect,
91    panel: Rect,
92    placement: Placement,
93    viewport: Rect,
94) -> (f32, f32) {
95    let fits_below =
96        trigger.y + trigger.height + ANCHOR_GAP + panel.height <= viewport.y + viewport.height;
97    let fits_above = trigger.y - ANCHOR_GAP - panel.height >= viewport.y;
98    let fits_after =
99        trigger.x + trigger.width + ANCHOR_GAP + panel.width <= viewport.x + viewport.width;
100    let fits_before = trigger.x - ANCHOR_GAP - panel.width >= viewport.x;
101    let placement = match placement {
102        Placement::Below if !fits_below && fits_above => Placement::Above,
103        Placement::Above if !fits_above && fits_below => Placement::Below,
104        Placement::Start if !fits_before && fits_after => Placement::End,
105        Placement::End if !fits_after && fits_before => Placement::Start,
106        other => other,
107    };
108    // Sideways placement centres on the trigger; the vertical ones align to its leading edge. LTR throughout,
109    // as `Below` has always been — flipping the whole function for RTL is one place to change.
110    let centre_y = trigger.y + (trigger.height - panel.height) / 2.0;
111    let (target_x, target_y) = match placement {
112        Placement::Below => (trigger.x, trigger.y + trigger.height + ANCHOR_GAP),
113        Placement::Above => (trigger.x, trigger.y - panel.height - ANCHOR_GAP),
114        Placement::Start => (trigger.x - panel.width - ANCHOR_GAP, centre_y),
115        Placement::End => (trigger.x + trigger.width + ANCHOR_GAP, centre_y),
116    };
117    // Slide along the edge rather than clamping blindly: a panel wider than the viewport keeps its left edge
118    // visible, which is where its content starts.
119    let max_x = viewport.x + viewport.width - panel.width - EDGE_MARGIN;
120    let target_x = target_x.min(max_x).max(viewport.x + EDGE_MARGIN);
121    let max_y = viewport.y + viewport.height - panel.height - EDGE_MARGIN;
122    let target_y = target_y.min(max_y.max(viewport.y)).max(viewport.y);
123    // On the pixel grid. A sideways placement centres on the trigger, so it lands on a half pixel whenever
124    // the panel and the trigger differ by an odd height — and a surface at a half pixel has soft edges and
125    // softer text inside it. Rounding a translate cannot move anything anywhere it should not be.
126    ((target_x - panel.x).round(), (target_y - panel.y).round())
127}
128
129/// The content rect an anchored overlay actually occupies on screen: its panel translated to the trigger.
130/// This is the hit-test barrier the registry sees, so only the visible panel blocks — clicks elsewhere fall
131/// through even though the underlying content node fills the viewport.
132/// Where the anchored panel ends up, and the translate that put it there.
133///
134/// Both answers come from one derivation — the panel union, then the flip/shift against the viewport — and
135/// both are wanted for the same pointer event: the registry hit-tests against the rect, and the dispatcher
136/// maps the event back into the children's space by the translate. Derived separately, the two could disagree
137/// about where the panel is while agreeing that the pointer was over it.
138fn anchored_placement(
139    children: &TrackedChildren,
140    anchor: &Anchor,
141    read: impl Fn(&RwSignal<Rect>) -> Rect,
142) -> (Rect, (f32, f32)) {
143    let panel = panel_rect(children, &read);
144    let (dx, dy) = anchor_translate(
145        read(&anchor.trigger),
146        panel,
147        anchor.placement,
148        anchor_viewport(),
149    );
150    (
151        Rect::new(panel.x + dx, panel.y + dy, panel.width, panel.height),
152        (dx, dy),
153    )
154}
155
156fn anchored_content_rect(
157    children: &TrackedChildren,
158    anchor: &Anchor,
159    read: impl Fn(&RwSignal<Rect>) -> Rect,
160) -> Rect {
161    anchored_placement(children, anchor, read).0
162}
163
164/// The overlay's hook into priority pointer routing. Shares the same `Rc<RefCell>` child handles as the
165/// `Overlay` widget (`Child` is a cheap clonable handle), so a pointer event dispatched through the sink
166/// reaches the very same content the widget renders. `content_rect` is the content container's layout rect,
167/// used as the hit-test barrier (a full-viewport scrim blocks everything; an anchored panel only itself).
168struct OverlaySinkImpl {
169    content_rect: RwSignal<Rect>,
170    children: RefCell<TrackedChildren>,
171    // Modal (swallow every event over the barrier) vs click-through (only where a child handled it).
172    blocking: bool,
173    // When set, the barrier and dispatch coordinates track the trigger instead of the fill container.
174    anchor: Option<Anchor>,
175    // A kept-mounted overlay whose `visible` reads false is inert: an empty barrier so it blocks nothing.
176    visible: Rc<dyn Fn() -> bool>,
177}
178
179impl OverlaySink for OverlaySinkImpl {
180    fn content_rect(&self) -> Rect {
181        // Hidden (kept mounted for a modal that toggles visibility): report an empty barrier so no pointer
182        // event routes to it and nothing behind is blocked.
183        if !(self.visible)() {
184            return Rect::default();
185        }
186        // peek, not get: routing runs during (batched) event dispatch, not inside a tracking effect.
187        match &self.anchor {
188            None => self.content_rect.peek(),
189            Some(anchor) => anchored_content_rect(&self.children.borrow(), anchor, |s| s.peek()),
190        }
191    }
192
193    fn dispatch(&self, event: &Event) -> EventResult {
194        // Anchored content is laid out at its intrinsic (un-anchored) origin but hit at the anchored spot,
195        // so map the world event back into the children's local space by the inverse translate first.
196        let offset = self
197            .anchor
198            .as_ref()
199            .map(|anchor| anchored_placement(&self.children.borrow(), anchor, |s| s.peek()).1);
200        match offset {
201            Some((dx, dy)) => {
202                // Map world → children-local space: local = world − translate. `offset_pointer(dx,dy)`
203                // applies the inverse of translate(dx,dy), i.e. subtracts it — so the sign is POSITIVE
204                // (matches scroll_area's use). Negating it double-adds the anchor offset and mishits rows.
205                let local = offset_pointer(event, dx as f64, dy as f64);
206                let event = local.as_ref().unwrap_or(event);
207                dispatch_container_event(&mut self.children.borrow_mut(), event)
208            }
209            None => dispatch_container_event(&mut self.children.borrow_mut(), event),
210        }
211    }
212
213    fn blocking(&self) -> bool {
214        self.blocking
215    }
216}
217
218/// A portal layer: its content is laid out out-of-flow, filling the viewport, and hoisted to the top at
219/// compose time — drawn above everything and free of any ancestor clip/transform. A base primitive:
220/// unstyled; wrap content in a `box` for a scrim/panel, and position it with normal flex (`align`/`justify`).
221///
222/// The content is a separate layout node **attached to the layout root** (the overlay host), not to the
223/// widget's DOM parent — so a portal declared deep in the tree (e.g. inside a reactive `if`) still covers
224/// the whole window instead of collapsing to its parent's box. The widget hands its DOM parent only a
225/// zero-size placeholder, so it never affects sibling layout. If no host has been laid out yet (a portal
226/// present at the very first frame), it falls back to laying the content out in place.
227///
228/// Positioned pointer events reach the content with priority via a thread-local overlay registry (see
229/// `ui_tree::overlay_dispatch`): a click on the overlay is routed here before the main tree walk and does
230/// not fall through to the content behind it, so a scrim that fills the viewport reads as a modal.
231///
232/// Variants (all portal the same way, they differ in how they route clicks and where the content sits):
233/// - [`Overlay::new`] — modal: blocks every click inside its content rect (a full-viewport scrim).
234/// - [`Overlay::anchored_click_through`] — positions the content next to a trigger widget (dropdowns, menus,
235///   tooltips) and takes no pointer, so clicks anywhere fall through to the tree behind.
236pub struct Overlay {
237    // Node handed to the DOM parent: a 0×0 placeholder (when portaled) or the content itself (fallback).
238    layout_node: NodeId,
239    // The viewport-filling content node; `Some` and attached to the host only when portaled.
240    portaled_content: Option<NodeId>,
241    children: TrackedChildren,
242    // Registry id for priority pointer routing; removed on drop.
243    overlay_id: u64,
244    // Focus-scope registration, withdrawn on drop alongside the pointer one.
245    focus_scope: crate::focus::ScopeId,
246    // Set for `anchored`: translates the rendered content to the trigger's rect (see `view`).
247    anchor: Option<Anchor>,
248    // Read each `view()`: when false the overlay draws nothing (kept mounted so its content — e.g. a modal's
249    // slotted body — survives a close/reopen instead of being rebuilt from a consumed slot).
250    visible: Rc<dyn Fn() -> bool>,
251}
252
253impl Overlay {
254    /// A modal portal: the content fills the viewport and blocks every click behind it.
255    pub fn new(
256        layout_style: LayoutStyle,
257        children: Vec<Box<dyn LayoutItem>>,
258    ) -> Result<Self, LayoutError> {
259        Self::build(layout_style, children, true, None, Rc::new(|| true))
260    }
261
262    /// A modal portal that is kept mounted and shown/hidden by `visible` (read each frame). Unlike disposing
263    /// and rebuilding the overlay on every open, this preserves its content across close/reopen — needed for a
264    /// dialog whose body arrives as a pre-built slot (which cannot be rebuilt once consumed). Hidden, it draws
265    /// nothing and blocks nothing.
266    pub fn toggleable(
267        layout_style: LayoutStyle,
268        children: Vec<Box<dyn LayoutItem>>,
269        visible: impl Fn() -> bool + 'static,
270    ) -> Result<Self, LayoutError> {
271        Self::build(layout_style, children, true, None, Rc::new(visible))
272    }
273
274    /// A portal whose content is positioned next to `trigger` (a dropdown/menu/tooltip popping up by its
275    /// button) and takes no pointer: a tooltip bubble, a hint, anything that appears because the pointer is
276    /// *near* it and would be dismissed by touching it. The content sizes to its intrinsic panel and is
277    /// translated to the trigger's rect per `placement`.
278    pub fn anchored_click_through(
279        layout_style: LayoutStyle,
280        children: Vec<Box<dyn LayoutItem>>,
281        trigger: RwSignal<Rect>,
282        placement: Placement,
283    ) -> Result<Self, LayoutError> {
284        Self::build(
285            layout_style,
286            children,
287            false,
288            Some(Anchor { trigger, placement }),
289            Rc::new(|| true),
290        )
291    }
292
293    fn build(
294        layout_style: LayoutStyle,
295        children: Vec<Box<dyn LayoutItem>>,
296        blocking: bool,
297        anchor: Option<Anchor>,
298        visible: Rc<dyn Fn() -> bool>,
299    ) -> Result<Self, LayoutError> {
300        // `absolute_fill` takes the layer out of flow and sizes it to its container; attaching it to the
301        // host makes that container the viewport. The caller's flex alignment positions content inside; an
302        // anchored overlay instead lets its content size intrinsically and moves it with a transform.
303        let (content, content_rect, children) =
304            register_container(layout_style.absolute_fill(), children)?;
305
306        // Register for priority pointer routing. The sink shares the same child handles as the widget.
307        let sink: Rc<dyn OverlaySink> = Rc::new(OverlaySinkImpl {
308            content_rect,
309            children: RefCell::new(children.clone()),
310            blocking,
311            anchor: anchor.clone(),
312            visible: visible.clone(),
313        });
314        let overlay_id = register_overlay(sink);
315        // The keyboard's half of the same barrier, named by node rather than by the ids inside it: the children were built before this overlay existed, so ancestry has to answer at the moment Tab is pressed.
316        let focus_scope = crate::focus::register_scope(
317            content,
318            {
319                let visible = visible.clone();
320                move || visible()
321            },
322            blocking,
323        );
324
325        if attach_overlay(content) {
326            // Portaled: the DOM parent gets a 0×0 placeholder so the portal takes no space in the flow.
327            let (placeholder, _r) =
328                crate::context::new_leaf(LayoutStyle::new().width(0.0).height(0.0))?;
329            Ok(Overlay {
330                layout_node: placeholder,
331                portaled_content: Some(content),
332                children,
333                overlay_id,
334                focus_scope,
335                anchor,
336                visible,
337            })
338        } else {
339            // No host yet: lay the content out in place (it will cover its parent, not the viewport).
340            Ok(Overlay {
341                layout_node: content,
342                portaled_content: None,
343                children,
344                overlay_id,
345                focus_scope,
346                anchor,
347                visible,
348            })
349        }
350    }
351}
352
353impl Overlay {
354    /// The node its content actually hangs from, which is the portaled one when it has a host and the in-tree
355    /// node before that. What a caller asks for to reason about the content by ancestry — autofocusing what is
356    /// inside it, say — since [`layout_node`](LayoutItem::layout_node) is a 0×0 placeholder once portaled.
357    pub fn content_node(&self) -> NodeId {
358        self.portaled_content.unwrap_or(self.layout_node)
359    }
360}
361
362impl LayoutItem for Overlay {
363    fn layout_node(&self) -> NodeId {
364        self.layout_node
365    }
366
367    /// An overlay is reached through the registry, before the tree walk, so its in-tree node must not
368    /// hit-test at all. Normally it is a 0×0 placeholder and the question never comes up; on the first frame,
369    /// before a host exists, the content is laid out in place and would otherwise cover its own siblings.
370    fn pointer_opaque(&self) -> bool {
371        false
372    }
373}
374
375impl Component for Overlay {
376    fn view(&self) -> RenderNode {
377        // Kept mounted but hidden: draw nothing (its content stays alive for the next time it is shown).
378        if !(self.visible)() {
379            return RenderNode::Empty;
380        }
381        let boundaries = self.children.iter().map(|c| c.segment.boundary());
382        match &self.anchor {
383            None => RenderNode::overlay(boundaries),
384            Some(anchor) => {
385                // `get` (not peek) so the transform re-runs when the trigger or the panel's size changes.
386                let panel = panel_rect(&self.children, |s| s.get());
387                let (dx, dy) = anchor_translate(
388                    anchor.trigger.get(),
389                    panel,
390                    anchor.placement,
391                    anchor_viewport(),
392                );
393                // Translate matrix `[1,0,0,1,dx,dy]`: the content is laid out at the origin, drawn at the trigger.
394                RenderNode::overlay([RenderNode::transform_with(
395                    [1.0, 0.0, 0.0, 1.0, dx, dy],
396                    boundaries,
397                )])
398            }
399        }
400    }
401
402    fn on_event(&mut self, event: &Event) -> EventResult {
403        // Positioned pointer events are delivered with priority through the overlay registry (before this
404        // in-tree walk reaches us); dispatching them here too would double-fire. Non-positioned events
405        // (keyboard shortcuts, CursorLeft) still flow through the tree, so forward those to the content.
406        if matches!(
407            event,
408            Event::PointerPressed { .. }
409                | Event::PointerMoved { .. }
410                | Event::PointerReleased { .. }
411        ) {
412            return EventResult::Ignored;
413        }
414        // `view` draws nothing while hidden and `content_rect` is an empty barrier; this was the one path that stayed open, so a shut dialog's field still received every key press.
415        // The settling events keep passing, or content hidden mid-gesture holds a hover with no event able to reach it and clear one.
416        if !(self.visible)() && !matches!(event, Event::CursorLeft | Event::FocusChanged { .. }) {
417            return EventResult::Ignored;
418        }
419        dispatch_container_event(&mut self.children, event)
420    }
421
422    fn debug_name(&self) -> &'static str {
423        "Overlay"
424    }
425}
426
427impl Drop for Overlay {
428    fn drop(&mut self) {
429        unregister_overlay(self.overlay_id);
430        crate::focus::unregister_scope(self.focus_scope);
431        // Detach the portaled content from the host and free it when the overlay is disposed (e.g. a
432        // reactive `if` hiding a modal) — it lives outside the DOM subtree, so nothing else removes it.
433        if let Some(content) = self.portaled_content {
434            detach_overlay(content);
435            remove_node(content);
436        }
437    }
438}
439
440#[cfg(test)]
441impl Overlay {
442    // The on-screen content rect (the hit-test barrier the registry sees) for an anchored overlay.
443    fn anchored_barrier(&self) -> Rect {
444        let anchor = self.anchor.as_ref().expect("overlay is not anchored");
445        anchored_content_rect(&self.children, anchor, |s| s.peek())
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use crate::context::reset_layout_runtime;
452    use layout_core::AvailableSpace;
453    use platform_core::{PointerButton, PointerSource};
454    use reactive_core::{RwSignal, signal};
455
456    use super::*;
457    use crate::ComponentList;
458
459    /// The other half of the same barrier, and what §1.1 of the audit actually asked for: while a modal is up,
460    /// Tab must not walk out to the content behind the scrim. The pointer has been blocked there all along;
461    /// the keyboard was not, because the tab order is a list and a list has no notion of in front or behind.
462    #[test]
463    fn a_modal_that_is_up_holds_tab_inside_itself() {
464        use crate::{StyledContainer, focus};
465
466        reset_layout_runtime();
467        let behind = focus::next_id();
468        focus::register_as(behind, focus::FocusKind::Widget);
469
470        let below = focus::next_id();
471        let inside = StyledContainer::new(
472            LayoutStyle::new().width(50.0).height(20.0),
473            |_r| renderer_core::RectStyle::default(),
474            vec![],
475        )
476        .unwrap()
477        .on_focus(|_| {});
478        let _overlay =
479            Overlay::toggleable(LayoutStyle::new(), vec![Box::new(inside)], || true).unwrap();
480        let above = focus::next_id();
481
482        focus::request(behind);
483        focus::focus_next();
484        let landed = focus::current().expect("something took focus");
485        assert!(
486            landed > below && landed < above,
487            "Tab left the modal that is up and landed on the content behind it"
488        );
489    }
490
491    /// `view` draws nothing while hidden and `content_rect` is an empty barrier, but the in-tree walk stayed
492    /// open — so every key press still reached the children of a dialog that was shut. The settling events
493    /// are the exception, or content hidden mid-gesture keeps a hover it has no way left to clear.
494    #[test]
495    fn a_hidden_overlay_does_not_take_the_keyboard() {
496        use std::cell::Cell;
497        use std::rc::Rc;
498
499        reset_layout_runtime();
500        let keys = Rc::new(Cell::new(0u32));
501        let counted = keys.clone();
502        let showing = signal(false);
503        let flag = showing.clone();
504        let field = crate::StyledContainer::new(
505            LayoutStyle::new().width(50.0).height(20.0),
506            |_r| renderer_core::RectStyle::default(),
507            vec![],
508        )
509        .unwrap()
510        .on_key(move |_| counted.set(counted.get() + 1));
511        let mut overlay =
512            Overlay::toggleable(LayoutStyle::new(), vec![Box::new(field)], move || {
513                flag.get()
514            })
515            .unwrap();
516
517        let press = Event::KeyPressed {
518            key: platform_core::Key::Char('a'),
519            modifiers: platform_core::ModifiersState::default(),
520        };
521        overlay.on_event(&press);
522        assert_eq!(keys.get(), 0, "a shut dialog takes no keys");
523
524        showing.set(true);
525        overlay.on_event(&press);
526        assert_eq!(keys.get(), 1, "and takes them again once it is up");
527
528        // Hidden mid-gesture: the settling events still get through, or the content keeps the look it had with nothing able to reach it.
529        showing.set(false);
530        assert_eq!(
531            overlay.on_event(&Event::CursorLeft),
532            crate::EventResult::Ignored,
533            "CursorLeft reaches the children (they simply had nothing to settle)"
534        );
535    }
536
537    /// The pointer path already scopes itself to what is on screen: a kept-mounted overlay whose `visible`
538    /// reads false is inert, an empty barrier that blocks nothing. The keyboard path does not, and the two
539    /// disagreeing is the bug — a focusable joins the tab order when its widget is *built*, and `toggleable`
540    /// builds its subtree once and keeps it mounted, so a field inside a dialog that is shut is still a Tab
541    /// stop. Not merely reachable past a scrim, as first described: reachable when nothing is open at all.
542    ///
543    /// Closed by naming a *node* rather than a set of ids: the overlay's children are constructed before the
544    /// overlay that will host them, so it never learns which focusables are its own, and ancestry answers at
545    /// the moment Tab is pressed instead.
546    #[test]
547    fn tab_does_not_walk_into_an_overlay_that_is_not_showing() {
548        use crate::{StyledContainer, focus};
549
550        reset_layout_runtime();
551        let base = focus::next_id();
552        focus::register_as(base, focus::FocusKind::Widget);
553
554        // Ids allocated between the two markers belong to whatever the overlay built.
555        let below = focus::next_id();
556        let field = StyledContainer::new(
557            LayoutStyle::new().width(50.0).height(20.0),
558            |_r| renderer_core::RectStyle::default(),
559            vec![],
560        )
561        .unwrap()
562        .on_focus(|_| {});
563        let _overlay =
564            Overlay::toggleable(LayoutStyle::new(), vec![Box::new(field)], || false).unwrap();
565        let above = focus::next_id();
566
567        focus::request(base);
568        focus::focus_next();
569        let landed = focus::current().expect("something took focus");
570        assert!(
571            !(landed > below && landed < above),
572            "Tab reached a focusable inside an overlay that is not showing"
573        );
574    }
575
576    /// A panel is placed from its trigger and then kept on screen. Without the second half, a tooltip on the
577    /// rightmost button of a toolbar is laid out past the window edge and its text wraps into a column — the
578    /// shape of the bug, not a cosmetic offset.
579    #[test]
580    fn an_anchored_panel_shifts_and_flips_to_stay_on_screen() {
581        let viewport = Rect::new(0.0, 0.0, 400.0, 300.0);
582        let panel = Rect::new(0.0, 0.0, 120.0, 60.0);
583
584        // Comfortably inside: under the trigger, a gap short of touching it.
585        let trigger = Rect::new(100.0, 100.0, 40.0, 20.0);
586        assert_eq!(
587            anchor_translate(trigger, panel, Placement::Below, viewport),
588            (100.0, 120.0 + ANCHOR_GAP)
589        );
590
591        // Against the right edge: slid left just enough to fit, still below the trigger.
592        let right = Rect::new(380.0, 100.0, 20.0, 20.0);
593        let (dx, dy) = anchor_translate(right, panel, Placement::Below, viewport);
594        assert_eq!((dx, dy), (400.0 - 120.0 - EDGE_MARGIN, 120.0 + ANCHOR_GAP));
595
596        // Against the bottom edge, with room above: flipped over the trigger rather than clamped onto it.
597        let low = Rect::new(100.0, 270.0, 40.0, 20.0);
598        let (_, dy) = anchor_translate(low, panel, Placement::Below, viewport);
599        assert_eq!(dy, 270.0 - 60.0 - ANCHOR_GAP, "opens upward instead");
600
601        // Nowhere to flip to (a panel taller than the viewport): pinned to the top, so its start is visible.
602        let tall = Rect::new(0.0, 0.0, 120.0, 400.0);
603        let (_, dy) = anchor_translate(low, tall, Placement::Below, viewport);
604        assert_eq!(dy, 0.0);
605    }
606
607    /// Beside the trigger, the panel centres on it and flips across it when its own side runs out — the same
608    /// two rules the vertical placements follow, on the other axis. A control in a vertical rail has no room
609    /// below it and all the room in the world beside it, which is why the sideways pair exists at all.
610    #[test]
611    fn a_panel_placed_beside_its_trigger_centres_on_it_and_flips_when_it_has_to() {
612        let viewport = Rect::new(0.0, 0.0, 400.0, 300.0);
613        let panel = Rect::new(0.0, 0.0, 120.0, 60.0);
614        let trigger = Rect::new(200.0, 100.0, 40.0, 20.0);
615
616        let (dx, dy) = anchor_translate(trigger, panel, Placement::End, viewport);
617        assert_eq!(
618            dx,
619            240.0 + ANCHOR_GAP,
620            "starts a gap past where the trigger ends"
621        );
622        assert_eq!(dy, 100.0 + (20.0 - 60.0) / 2.0, "centred on the trigger");
623
624        let (dx, _) = anchor_translate(trigger, panel, Placement::Start, viewport);
625        assert_eq!(
626            dx,
627            80.0 - ANCHOR_GAP,
628            "ends a gap before the trigger starts"
629        );
630
631        // A rail down the left edge: there is no room before the trigger, so the panel takes the other side.
632        let rail = Rect::new(4.0, 100.0, 40.0, 20.0);
633        let (dx, _) = anchor_translate(rail, panel, Placement::Start, viewport);
634        assert_eq!(dx, 44.0 + ANCHOR_GAP, "flipped to the trailing side");
635
636        // Centring on a trigger of a different height lands on a half pixel, and a surface at a half pixel
637        // has soft edges and softer text: a tooltip beside a 36px button was crisp and the same one under a
638        // 28px tab was blurred, from nothing but the fraction each placement contributed.
639        let odd = Rect::new(200.0, 100.0, 40.0, 21.0);
640        let (dx, dy) = anchor_translate(odd, panel, Placement::End, viewport);
641        assert_eq!((dx, dy), (dx.round(), dy.round()), "on the pixel grid");
642    }
643    use crate::container::Container;
644    use crate::context::compute_layout;
645
646    fn press(x: f64, y: f64) -> Event {
647        Event::PointerPressed {
648            x,
649            y,
650            button: PointerButton::Primary,
651            source: PointerSource::Mouse,
652        }
653    }
654    fn release(x: f64, y: f64) -> Event {
655        Event::PointerReleased {
656            x,
657            y,
658            button: PointerButton::Primary,
659            source: PointerSource::Mouse,
660        }
661    }
662
663    // Mirror the runner: consult the overlay registry first, then walk the tree only if no overlay
664    // consumed the event. (Production does this in `handler.rs` via the `App::dispatch_overlays` bridge.)
665    fn route(tree: &mut ComponentList, event: &Event) {
666        if crate::dispatch_overlays(event) == EventResult::Ignored {
667            tree.on_event(event);
668        }
669    }
670
671    // A container filling 400×400 whose on_press flips `flag`, used as both the modal scrim and the
672    // background it covers.
673    fn pressable(flag: RwSignal<bool>) -> Container {
674        Container::new(LayoutStyle::new().width(400.0).height(400.0), vec![])
675            .unwrap()
676            .on_press(move || flag.set(true))
677    }
678
679    // Baseline (guards the assertion below from being vacuous): with no overlay, a tap on the background
680    // fires its on_press.
681    #[test]
682    fn background_alone_receives_tap() {
683        reset_layout_runtime();
684        let clicked = signal(false);
685        let bg = pressable(clicked.clone());
686        let root = Container::new(
687            LayoutStyle::new().flex_column().width(400.0).height(400.0),
688            vec![Box::new(bg)],
689        )
690        .unwrap();
691        let root_node = root.layout_node();
692        compute_layout(
693            root_node,
694            AvailableSpace::Definite(400.0),
695            AvailableSpace::Definite(400.0),
696        )
697        .unwrap();
698        let mut tree = ComponentList::new(root);
699        let _ = tree.commands();
700
701        route(&mut tree, &press(200.0, 200.0));
702        route(&mut tree, &release(200.0, 200.0));
703        assert!(
704            clicked.get(),
705            "background on_press must fire without an overlay"
706        );
707    }
708
709    // The fix: an overlay is hit-tested before the tree, so a tap over it reaches the overlay's content
710    // (the scrim) and is blocked from the background it covers.
711    #[test]
712    fn overlay_receives_tap_and_blocks_background() {
713        reset_layout_runtime();
714        let bg_clicked = signal(false);
715        let overlay_clicked = signal(false);
716
717        let bg = pressable(bg_clicked.clone());
718        // The scrim fills the overlay (which `absolute_fill`s the root), so it covers the background.
719        let scrim = Container::new(LayoutStyle::new().width(400.0).height(400.0), vec![])
720            .unwrap()
721            .on_press({
722                let s = overlay_clicked.clone();
723                move || s.set(true)
724            });
725        let overlay = Overlay::new(LayoutStyle::new(), vec![Box::new(scrim)]).unwrap();
726        let root = Container::new(
727            LayoutStyle::new().flex_column().width(400.0).height(400.0),
728            vec![Box::new(bg), Box::new(overlay)],
729        )
730        .unwrap();
731        let root_node = root.layout_node();
732        compute_layout(
733            root_node,
734            AvailableSpace::Definite(400.0),
735            AvailableSpace::Definite(400.0),
736        )
737        .unwrap();
738        let mut tree = ComponentList::new(root);
739        let _ = tree.commands();
740
741        // A tap at the center hits both the background and the overlay; the overlay must win.
742        route(&mut tree, &press(200.0, 200.0));
743        route(&mut tree, &release(200.0, 200.0));
744
745        assert!(
746            overlay_clicked.get(),
747            "the tap must reach the overlay content"
748        );
749        assert!(
750            !bg_clicked.get(),
751            "the overlay must block the tap from the content behind it"
752        );
753    }
754
755    // The real modal scenario: the page is laid out first (registering the overlay host), THEN the modal
756    // opens and portals its content to the host (attach_overlay succeeds). This exercises the portaled
757    // path — where `content_rect` is driven to the viewport by a later relayout — not the in-place
758    // fallback the test above hits (overlay built before any layout host exists).
759    #[test]
760    fn portaled_overlay_blocks_background() {
761        use crate::context::relayout_if_dirty;
762
763        reset_layout_runtime();
764        let bg_clicked = signal(false);
765
766        // 1. Lay out the page first: this registers `root` as the overlay host.
767        let bg = pressable(bg_clicked.clone());
768        let root = Container::new(
769            LayoutStyle::new().flex_column().width(400.0).height(400.0),
770            vec![Box::new(bg)],
771        )
772        .unwrap();
773        let root_node = root.layout_node();
774        compute_layout(
775            root_node,
776            AvailableSpace::Definite(400.0),
777            AvailableSpace::Definite(400.0),
778        )
779        .unwrap();
780        let mut tree = ComponentList::new(root);
781        let _ = tree.commands();
782
783        // 2. Now open the modal: its content portals to the host and fills the viewport after relayout.
784        let overlay_clicked = signal(false);
785        let scrim = Container::new(LayoutStyle::new().width(400.0).height(400.0), vec![])
786            .unwrap()
787            .on_press({
788                let s = overlay_clicked.clone();
789                move || s.set(true)
790            });
791        let _overlay = Overlay::new(LayoutStyle::new(), vec![Box::new(scrim)]).unwrap();
792        relayout_if_dirty();
793
794        // 3. A tap at the center must reach the portaled overlay and be blocked from the page behind it.
795        route(&mut tree, &press(200.0, 200.0));
796        route(&mut tree, &release(200.0, 200.0));
797
798        assert!(
799            overlay_clicked.get(),
800            "the tap must reach the portaled overlay content"
801        );
802        assert!(
803            !bg_clicked.get(),
804            "the portaled overlay must block the tap from the page behind it"
805        );
806    }
807
808    // Deliverable 1 at the widget level: a click-through overlay with a small panel lets a tap on its
809    // transparent area reach the background, but still consumes a tap that lands on the panel.
810    #[test]
811    fn click_through_overlay_lets_background_tap_through() {
812        reset_layout_runtime();
813        let bg_clicked = signal(false);
814        let panel_clicked = signal(false);
815
816        let bg = pressable(bg_clicked.clone());
817        // A 100×100 panel in the top-left corner; the rest of the click-through layer is transparent.
818        let panel = Container::new(LayoutStyle::new().width(100.0).height(100.0), vec![])
819            .unwrap()
820            .on_press({
821                let s = panel_clicked.clone();
822                move || s.set(true)
823            });
824        let overlay = Overlay::build(
825            LayoutStyle::new(),
826            vec![Box::new(panel)],
827            false,
828            None,
829            Rc::new(|| true),
830        )
831        .unwrap();
832        let root = Container::new(
833            LayoutStyle::new().flex_column().width(400.0).height(400.0),
834            vec![Box::new(bg), Box::new(overlay)],
835        )
836        .unwrap();
837        let root_node = root.layout_node();
838        compute_layout(
839            root_node,
840            AvailableSpace::Definite(400.0),
841            AvailableSpace::Definite(400.0),
842        )
843        .unwrap();
844        let mut tree = ComponentList::new(root);
845        let _ = tree.commands();
846
847        // A tap outside the panel falls through the transparent layer to the background.
848        route(&mut tree, &press(200.0, 200.0));
849        route(&mut tree, &release(200.0, 200.0));
850        assert!(
851            bg_clicked.get(),
852            "a tap on the transparent area must reach the background"
853        );
854        assert!(
855            !panel_clicked.get(),
856            "the panel must not receive a tap outside it"
857        );
858
859        // A tap on the panel is consumed by the overlay and does not reach the background.
860        bg_clicked.set(false);
861        route(&mut tree, &press(50.0, 50.0));
862        route(&mut tree, &release(50.0, 50.0));
863        assert!(panel_clicked.get(), "a tap on the panel must reach it");
864        assert!(
865            !bg_clicked.get(),
866            "the panel must block the tap from the background"
867        );
868    }
869
870    // Deliverable 2: an anchored overlay's on-screen content rect origin tracks its trigger rect, and
871    // follows the trigger when it moves — proving the content is positioned against the trigger, not the fill.
872    #[test]
873    fn anchored_content_tracks_trigger() {
874        use crate::context::relayout_if_dirty;
875
876        reset_layout_runtime();
877
878        // 1. Lay out a page first so the overlay host exists (the anchored content portals to it).
879        let root = Container::new(
880            LayoutStyle::new().flex_column().width(400.0).height(400.0),
881            vec![],
882        )
883        .unwrap();
884        let root_node = root.layout_node();
885        compute_layout(
886            root_node,
887            AvailableSpace::Definite(400.0),
888            AvailableSpace::Definite(400.0),
889        )
890        .unwrap();
891        let tree = ComponentList::new(root);
892        let _ = tree.commands();
893
894        // 2. Open an anchored overlay below a trigger, with a fixed 120×60 panel.
895        let trigger = signal(Rect::new(50.0, 20.0, 80.0, 30.0));
896        let panel = Container::new(LayoutStyle::new().width(120.0).height(60.0), vec![]).unwrap();
897        let overlay = Overlay::build(
898            LayoutStyle::new(),
899            vec![Box::new(panel)],
900            true,
901            Some(Anchor {
902                trigger: trigger.clone(),
903                placement: Placement::Below,
904            }),
905            Rc::new(|| true),
906        )
907        .unwrap();
908        relayout_if_dirty();
909
910        // Below: the content sits at the trigger's bottom-left (50, 20 + 30), a gap short of touching it.
911        let rect = overlay.anchored_barrier();
912        assert_eq!((rect.x, rect.y), (50.0, 50.0 + ANCHOR_GAP));
913        assert_eq!((rect.width, rect.height), (120.0, 60.0));
914
915        // Move the trigger; the anchored content origin follows it (no relayout needed — it is a transform).
916        trigger.set(Rect::new(200.0, 100.0, 80.0, 30.0));
917        let rect = overlay.anchored_barrier();
918        assert_eq!((rect.x, rect.y), (200.0, 130.0 + ANCHOR_GAP));
919        assert_eq!((rect.width, rect.height), (120.0, 60.0));
920    }
921}