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}