frust_widgets/overlay.rs
1//! The widget-author face of the overlay portal: where a floated surface is
2//! placed, the slot a widget that hosts its own surface keeps, and the
3//! declarative wrapper for the common case.
4//!
5//! [`frust_core::overlay`] owns the *mechanism* — a per-paint registry the
6//! render root drains, paints above the whole main tree and hit-tests before
7//! it. This module owns the three things every caller of that mechanism would
8//! otherwise write for itself:
9//!
10//! * [`place`] — the anchored placement geometry (a side, a cross-axis
11//! alignment, a gap, a collision flip and a shift-back-inside clamp). Pure,
12//! total and usable on its own.
13//! * [`OverlaySlot`] — the owner's half of the portal: it holds the pod, lays
14//! it out against the window, computes its window rect and registers it every
15//! paint, and routes the broadcast the root sends back into it. A widget that
16//! floats a surface of its own (a field hosting a selection toolbar, a menu
17//! button) holds one of these and forwards four calls to it.
18//! * [`overlay_portal`] — the declarative wrapper for the common case: a child,
19//! an optional overlay view anchored to that child's bounds, and the portal
20//! does the rest.
21//!
22//! # Coordinate spaces
23//!
24//! Three spaces meet here, and every bug in a floating surface is a confusion
25//! between two of them:
26//!
27//! * **Window space** — absolute logical pixels. The registered
28//! [`OverlayEntry::window_rect`](frust_core::OverlayEntry::window_rect) is in
29//! it, the root hit-tests in it, and the payload of an
30//! [`InputEvent::Overlay`] is in it. A widget only ever learns its own window
31//! position in `paint`, from
32//! [`PaintCtx::origin`](frust_core::PaintCtx::origin) — which is why the
33//! placement is computed there and nowhere else.
34//! * **Owner-local space** — what the owner's own `event` sees: every container
35//! between the root and the owner has already subtracted its origin. An
36//! ordinary pointer event routed to the owner by a live capture arrives here,
37//! so the slot adds the owner origin it recorded at paint time to get back to
38//! window space.
39//! * **Pod space** — the floated pod's own local space, which is window space
40//! minus the placed rect's origin. That single subtraction is the whole
41//! translation into the surface.
42//!
43//! # Capture lifetime
44//!
45//! A pod that captures the pointer records the ordinary
46//! [`frust_core::ChildPod::is_active`] link, and [`OverlaySlot`] clears it on
47//! the `Up`/`Cancel` that ends the gesture which opened it — the same rule the
48//! authoring toolkit's `route_event_single` and the component boundary's own
49//! router apply to every other captured child. The slot dispatches into its pod
50//! directly rather than through either helper (the substituted route below
51//! needs a context it builds itself), so it owns that half of the container
52//! contract too.
53//!
54//! The alternative — leaving the link latched and reading the capture off the
55//! dispatch's own flag instead — was not taken. Both of the slot's capture
56//! mirrors read the *edge* into that link rather than its level, so a link that
57//! is set once and never cleared makes the edge observable once per pod
58//! instance rather than once per gesture, and a surface that stays mounted
59//! loses every gesture after the first. The link's *level* has a reader of its
60//! own besides — a pod holding it is refused a hover claim — which a latch that
61//! never falls would strand just as permanently. Restoring the link's lifetime
62//! answers both; re-deriving one mirror would leave the latch standing for the
63//! other. The slot's own "these owner-local pointer events are the surface's"
64//! flag is then derived from the link rather than latched beside it, so the two
65//! cannot drift apart.
66//!
67//! # Focus lifetime
68//!
69//! A pod's focus link has the same problem the capture link above had, one level
70//! up: a container clears a child's `focused` flag from its own
71//! blur-on-outside-tap sweep, and no container holds this pod, so no sweep ever
72//! reaches it. Nothing here fixes that by hand, and deliberately so — a fix that
73//! lived in this module would cover the surfaces `OverlaySlot` owns and leave
74//! every hand-written owner (and every direct `PaintCtx::register_overlay`
75//! caller) with the original defect.
76//!
77//! `frust-core` gives the link a lifetime instead, where every claim already
78//! passes: a recorded link carries the identity of the focus session it was
79//! recorded for, and the render root moves that identity on whenever a dispatch
80//! records a new claim or ends the session. A link whose stamp names an older
81//! session is retired by arithmetic, with no pass having to visit the branch it
82//! belongs to — and the root retires the flag itself the next time it holds the
83//! pod, during the paint that floats it
84//! ([`ChildPod::retire_stale_focus_link`](frust_core::ChildPod::retire_stale_focus_link)).
85//!
86//! What this module owes that mechanism is to ask the right question:
87//! [`OverlaySlot::pod_has_focus`] reports a link on the **live** session, not a
88//! flag that was once set, and every routing decision here reads it.
89//!
90//! # Not in v1
91//!
92//! * **The pod contributes no semantics.** A pod's nodes would attach under the
93//! owner's own accessibility node, at the owner's position rather than the
94//! floated rect's, so nothing is published rather than something wrong. An
95//! assistive-technology user reaches a floated surface through the owner
96//! (a field's own actions, a trigger's own node), not through the surface.
97//! * **No hover inside a pod.** The root marks a hover pass on a hit-tested
98//! pointer event only, and an overlay event is a broadcast, so
99//! [`EventCtx::claim_hover`](frust_core::EventCtx::claim_hover) inside a
100//! floated surface records nothing.
101//! * **A substituted pod publishes no IME surface** (see
102//! [`OverlaySlot::event`]); a pod over the ambient application state
103//! ([`OverlaySlot::event_ambient`], the route [`overlay_portal`] takes)
104//! publishes normally.
105//! * **No focus trap and no nesting**, per [`frust_core::overlay`]'s own list.
106
107use std::any::Any;
108use std::cell::RefCell;
109use std::marker::PhantomData;
110use std::rc::Rc;
111
112use frust_core::{
113 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
114 LayoutCtx, OutsideTap, OverlayBand, OverlayEntry, OverlayEventKind, OverlayInput, OverlayKey,
115 PaintCtx, PaintScene, PointerEvent, SemanticsCtx, View, Widget, any,
116};
117use kurbo::{Point, Rect, Size};
118
119use crate::authoring::{ErasedCallback, erase_callback, releases_capture, route_event_single};
120
121// ---------------------------------------------------------------------------
122// Placement
123// ---------------------------------------------------------------------------
124
125/// The side of the anchor a floated surface opens on.
126///
127/// The web vocabulary every anchored-overlay pattern in this workspace already
128/// speaks (Radix's `side`, and the four-sided tooltip variants each catalog
129/// grew on top of it).
130#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
131pub enum OverlaySide {
132 /// Above the anchor.
133 Top,
134 /// To the trailing side of the anchor.
135 Right,
136 /// Below the anchor — the default.
137 #[default]
138 Bottom,
139 /// To the leading side of the anchor.
140 Left,
141}
142
143impl OverlaySide {
144 /// The side a collision flip lands on.
145 pub const fn opposite(self) -> Self {
146 match self {
147 OverlaySide::Top => OverlaySide::Bottom,
148 OverlaySide::Bottom => OverlaySide::Top,
149 OverlaySide::Left => OverlaySide::Right,
150 OverlaySide::Right => OverlaySide::Left,
151 }
152 }
153
154 /// Whether this side stacks the surface vertically (`Top`/`Bottom`).
155 pub const fn is_vertical(self) -> bool {
156 matches!(self, OverlaySide::Top | OverlaySide::Bottom)
157 }
158}
159
160/// How a floated surface lines up with its anchor on the cross axis.
161#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
162pub enum OverlayAlign {
163 /// Leading edges flush (left edges for a `Top`/`Bottom` side, top edges for
164 /// a `Left`/`Right` one).
165 Start,
166 /// Centres flush — the default.
167 #[default]
168 Center,
169 /// Trailing edges flush.
170 End,
171}
172
173/// A resolved placement request: which side, how it lines up, how far off the
174/// anchor it sits, and how it may move to stay inside the area.
175#[derive(Clone, Copy, Debug, PartialEq)]
176pub struct OverlayPlacement {
177 /// The preferred side.
178 pub side: OverlaySide,
179 /// Cross-axis alignment.
180 pub align: OverlayAlign,
181 /// Gap between the anchor and the surface, in logical px.
182 pub offset: f64,
183 /// Flip to [`OverlaySide::opposite`] when the preferred side does not fit
184 /// and the opposite one does.
185 pub flip: bool,
186 /// Shift the placed rect back inside the padded area when it overflows.
187 pub clamp: bool,
188 /// The margin the surface keeps from every edge of the area, in logical px.
189 /// Both the fit test and the clamp read the area inset by it.
190 pub padding: f64,
191}
192
193/// The default gap between an anchor and the surface placed against it, in
194/// logical px.
195///
196/// The small neutral gap two of the three anchored hosts in this workspace
197/// already default to; a design system that wants a wider one (a panel with a
198/// visible neck) sets [`OverlayPlacement::offset`] in its own wrapper rather
199/// than changing this.
200pub const DEFAULT_OFFSET: f64 = 4.0;
201
202/// The default margin a placed surface keeps from every edge of the area, in
203/// logical px — the viewport padding the collision clamp works against.
204pub const DEFAULT_PADDING: f64 = 8.0;
205
206impl Default for OverlayPlacement {
207 /// Below the anchor, centred, at [`DEFAULT_OFFSET`], flipping and clamping
208 /// inside [`DEFAULT_PADDING`] of the area's edges.
209 fn default() -> Self {
210 OverlayPlacement {
211 side: OverlaySide::default(),
212 align: OverlayAlign::default(),
213 offset: DEFAULT_OFFSET,
214 flip: true,
215 clamp: true,
216 padding: DEFAULT_PADDING,
217 }
218 }
219}
220
221impl OverlayPlacement {
222 /// A placement on `side`, everything else defaulted.
223 pub fn on(side: OverlaySide) -> Self {
224 OverlayPlacement {
225 side,
226 ..Self::default()
227 }
228 }
229
230 /// Set the cross-axis alignment.
231 pub const fn align(mut self, align: OverlayAlign) -> Self {
232 self.align = align;
233 self
234 }
235
236 /// Set the anchor gap, in logical px.
237 pub const fn offset(mut self, offset: f64) -> Self {
238 self.offset = offset;
239 self
240 }
241
242 /// Enable or disable the collision flip.
243 pub const fn flip(mut self, flip: bool) -> Self {
244 self.flip = flip;
245 self
246 }
247
248 /// Enable or disable the shift-back-inside clamp.
249 pub const fn clamp(mut self, clamp: bool) -> Self {
250 self.clamp = clamp;
251 self
252 }
253
254 /// Set the margin kept from the area's edges, in logical px.
255 pub const fn padding(mut self, padding: f64) -> Self {
256 self.padding = padding;
257 self
258 }
259}
260
261/// The region a surface may occupy: `area` inset by `padding` on every side.
262///
263/// Falls back to `area` itself when the inset would invert it — a window
264/// narrower than twice the padding still has to place its surface somewhere,
265/// and an inverted rect would make the clamp below meaningless.
266fn field(area: Rect, padding: f64) -> Rect {
267 let inset = area.inset(-padding);
268 if inset.width() > 0.0 && inset.height() > 0.0 {
269 inset
270 } else {
271 area
272 }
273}
274
275/// Whether a `content`-sized surface fits on `side` of `anchor` inside `field`.
276fn fits(side: OverlaySide, anchor: Rect, content: Size, field: Rect, offset: f64) -> bool {
277 match side {
278 OverlaySide::Top => anchor.y0 - offset - content.height >= field.y0,
279 OverlaySide::Bottom => anchor.y1 + offset + content.height <= field.y1,
280 OverlaySide::Left => anchor.x0 - offset - content.width >= field.x0,
281 OverlaySide::Right => anchor.x1 + offset + content.width <= field.x1,
282 }
283}
284
285/// The cross-axis start coordinate for `align`, given the anchor's own span
286/// `[a0, a1]` and the surface's `extent` along that axis.
287fn align_start(align: OverlayAlign, a0: f64, a1: f64, extent: f64) -> f64 {
288 match align {
289 OverlayAlign::Start => a0,
290 OverlayAlign::Center => (a0 + a1) / 2.0 - extent / 2.0,
291 OverlayAlign::End => a1 - extent,
292 }
293}
294
295/// Shift `rect` back inside `field`, keeping its size.
296///
297/// The start edge wins when the surface is larger than the field (`min` before
298/// `max`): a too-wide panel hangs off the trailing edge rather than the leading
299/// one, where its content starts.
300fn clamp_into(rect: Rect, field: Rect) -> Rect {
301 let x = rect.x0.min(field.x1 - rect.width()).max(field.x0);
302 let y = rect.y0.min(field.y1 - rect.height()).max(field.y0);
303 Rect::from_origin_size(Point::new(x, y), rect.size())
304}
305
306/// Place a `content`-sized surface against `anchor` inside `area`.
307///
308/// All three rects are in one coordinate space — window space, for every caller
309/// inside this module. The returned rect is the surface's placed bounds:
310///
311/// 1. inset `area` by `placement.padding` — every step below works in that
312/// field;
313/// 2. pick the side: the preferred one, or its opposite when `flip` is on, the
314/// preferred one does not fit and the opposite one does;
315/// 3. offset off that edge of the anchor by `offset`, lined up on the cross axis
316/// per `align`;
317/// 4. shift back inside the field when `clamp` is on.
318///
319/// Pure and total: it allocates nothing, reads no context, and is defined for a
320/// degenerate anchor (a zero-size rect places against that point) and for
321/// content larger than the field (which pins to the field's *start* edge).
322///
323/// This is the one copy of geometry each anchored-overlay host in the design
324/// systems had derived for itself (`plugins/shadcn`, `plugins/beui` and
325/// `plugins/material`'s `overlay::anchored`), promoted here so a widget, a
326/// catalog and an app all place a surface the same way.
327pub fn place(anchor: Rect, content: Size, area: Rect, placement: OverlayPlacement) -> Rect {
328 let field = field(area, placement.padding);
329 let mut side = placement.side;
330 if placement.flip
331 && !fits(side, anchor, content, field, placement.offset)
332 && fits(side.opposite(), anchor, content, field, placement.offset)
333 {
334 side = side.opposite();
335 }
336 let origin = match side {
337 OverlaySide::Top => Point::new(
338 align_start(placement.align, anchor.x0, anchor.x1, content.width),
339 anchor.y0 - placement.offset - content.height,
340 ),
341 OverlaySide::Bottom => Point::new(
342 align_start(placement.align, anchor.x0, anchor.x1, content.width),
343 anchor.y1 + placement.offset,
344 ),
345 OverlaySide::Left => Point::new(
346 anchor.x0 - placement.offset - content.width,
347 align_start(placement.align, anchor.y0, anchor.y1, content.height),
348 ),
349 OverlaySide::Right => Point::new(
350 anchor.x1 + placement.offset,
351 align_start(placement.align, anchor.y0, anchor.y1, content.height),
352 ),
353 };
354 let rect = Rect::from_origin_size(origin, content);
355 if placement.clamp {
356 clamp_into(rect, field)
357 } else {
358 rect
359 }
360}
361
362// ---------------------------------------------------------------------------
363// The owner's slot
364// ---------------------------------------------------------------------------
365
366/// What a slot places its surface against.
367///
368/// Both variants resolve to a **window-space** rect in the owner's `paint`,
369/// where the owner's absolute origin is finally known; nothing is subscribed to
370/// and nothing is cached across frames, so an anchor follows its owner across
371/// scroll, relayout and animation for free.
372#[derive(Clone, Copy, Debug, Default, PartialEq)]
373pub enum OverlayAnchor {
374 /// The owner's own bounds — a trigger floating a menu under itself.
375 #[default]
376 Owner,
377 /// A rect in the **owner's local space**: a caret, a selection's bounding
378 /// box, a press point, one row of a list. Translated by the owner's paint
379 /// origin, so a caller states it in the same coordinates its `layout` and
380 /// `event` already speak.
381 Rect(Rect),
382}
383
384/// One floated surface an owner hosts: the pod, where it goes, and the routing
385/// of the input the root sends back to it.
386///
387/// # The four calls
388///
389/// An owner widget forwards four of its own lifecycle calls here, and the slot
390/// does nothing on its own:
391///
392/// 1. [`rebuild`](Self::rebuild) from the owner's `View::rebuild`, with the
393/// overlay view it wants mounted (or `None` to drop it).
394/// 2. [`layout`](Self::layout) from the owner's `Widget::layout` — the pod is
395/// laid out loosely against the **window**, never the owner's own
396/// constraints, because it escapes the owner's box entirely.
397/// 3. [`paint`](Self::paint) from the owner's `Widget::paint`, which computes
398/// the placement and registers the pod. It paints nothing: the root paints
399/// every registered pod after the main tree, which is the only way a surface
400/// escapes its owner's paint order and every ancestor's clip.
401/// 4. [`event`](Self::event) (or [`event_ambient`](Self::event_ambient)) from
402/// the owner's `Widget::event`, **before** the owner routes to its own
403/// children. `None` means "not mine" and the owner carries on.
404///
405/// # `PodState`
406///
407/// The state type the floated view is diffed against. Two shapes exist and the
408/// dispatch call differs between them:
409///
410/// * the pod is built over the **ambient application state** (what
411/// [`overlay_portal`] does): use [`event_ambient`](Self::event_ambient), which
412/// forwards through the owner's own [`EventCtx`] so focus, capture, hover and
413/// IME all bubble exactly as they do for any other child;
414/// * the pod is built over a **different** state — `()` for a
415/// framework-built surface whose callbacks carry their own handles: use
416/// [`event`](Self::event) and hand it `&mut PodState`, which dispatches over a
417/// substituted context (see that method for what does and does not bubble
418/// through the substitution).
419pub struct OverlaySlot<PodState: 'static> {
420 /// This surface's identity, allocated once and quoted back by every routed
421 /// event. Stable for the slot's life — re-allocating per frame would hand
422 /// the root a new identity every paint.
423 key: OverlayKey,
424 /// The mounted pod, shared with the registration the root paints. `None`
425 /// while the surface is closed.
426 pod: Option<Rc<RefCell<ChildPod>>>,
427 band: OverlayBand,
428 input: OverlayInput,
429 outside_tap: OutsideTap,
430 placement: OverlayPlacement,
431 anchor: OverlayAnchor,
432 /// The window size the last layout pass saw — the area the placement is
433 /// computed against, recorded in `layout` because a paint context carries
434 /// no window size of its own.
435 window: Size,
436 /// Where the last paint placed the surface, in window space: what was
437 /// registered, what the root hit-tests, and what a routed position is made
438 /// pod-local against.
439 window_rect: Rect,
440 /// The owner's own absolute origin as of the last paint — what an
441 /// owner-local pointer position (a captured drag) is lifted into window
442 /// space with.
443 owner_origin: Point,
444 /// Whether the pod holds the pointer capture, so ordinary pointer events
445 /// routed to the owner by the capture path belong to the surface.
446 ///
447 /// Recomputed from the pod's own recorded link by every dispatch through
448 /// `forward` — the one writer while a pod is mounted — rather than latched
449 /// alongside it. Mounting, dropping or replacing the pod resets it for the
450 /// same reason: the link it mirrors goes with the widget that held it.
451 captured: bool,
452 /// A press landed outside every floated surface and this one asked to hear
453 /// about it; drained by [`take_outside_down`](Self::take_outside_down).
454 outside_down_pending: bool,
455 _state: PhantomData<fn(&mut PodState)>,
456}
457
458impl<PodState: 'static> Default for OverlaySlot<PodState> {
459 fn default() -> Self {
460 Self::new()
461 }
462}
463
464impl<PodState: 'static> OverlaySlot<PodState> {
465 /// A closed slot with a fresh identity: `Floating`, interactive, ignoring
466 /// outside taps, anchored to its owner's own bounds.
467 ///
468 /// Built **once**, when the owner widget is built, and kept: the key is the
469 /// whole addressing mechanism between the root and this surface.
470 pub fn new() -> Self {
471 Self {
472 key: OverlayKey::next(),
473 pod: None,
474 band: OverlayBand::Floating,
475 input: OverlayInput::Interactive,
476 outside_tap: OutsideTap::Ignore,
477 placement: OverlayPlacement::default(),
478 anchor: OverlayAnchor::Owner,
479 window: Size::ZERO,
480 window_rect: Rect::ZERO,
481 owner_origin: Point::ZERO,
482 captured: false,
483 outside_down_pending: false,
484 _state: PhantomData,
485 }
486 }
487
488 /// This surface's identity.
489 pub fn key(&self) -> OverlayKey {
490 self.key
491 }
492
493 /// Whether a pod is currently mounted.
494 pub fn is_open(&self) -> bool {
495 self.pod.is_some()
496 }
497
498 /// Where the last paint placed the surface, in window space.
499 ///
500 /// [`Rect::ZERO`] before the first paint of an open slot — a surface that
501 /// has never been painted has never been registered, so nothing routes to
502 /// it either.
503 pub fn window_rect(&self) -> Rect {
504 self.window_rect
505 }
506
507 /// Whether the pod holds a focus link on the **live** session — the read
508 /// every routing decision here makes.
509 ///
510 /// Not the raw recorded flag: a claim made from inside a floated surface
511 /// reaches no container's blur sweep, so a link this surface recorded
512 /// survives the session moving away from it (see the module docs' *Focus
513 /// lifetime*). Asking the raw flag would keep routing the keyboard into a
514 /// surface the user has left.
515 pub fn pod_has_focus(&self) -> bool {
516 self.pod
517 .as_ref()
518 .is_some_and(|pod| pod.borrow().holds_live_focus())
519 }
520
521 /// Drop the pod's recorded focus link, so focus-routed events stop reaching
522 /// it — the owner's half of "the surface asked for focus, and the owner
523 /// declined on its behalf".
524 ///
525 /// An owner's *decision*, and the only reason this exists: a link the
526 /// session has merely moved away from needs no call, since its stamp retires
527 /// it on its own (see the module docs' *Focus lifetime*).
528 pub fn withdraw_pod_focus(&mut self) {
529 if let Some(pod) = &self.pod {
530 pod.borrow_mut().set_focused(false);
531 }
532 }
533
534 /// Take the pending outside-press notification, clearing it.
535 ///
536 /// `true` exactly once per press that landed outside every floated surface
537 /// while this one was registered [`OutsideTap::Notify`] — the light-dismiss
538 /// signal an owner closes on.
539 pub fn take_outside_down(&mut self) -> bool {
540 std::mem::take(&mut self.outside_down_pending)
541 }
542
543 /// Which z-band the surface paints and hit-tests in.
544 pub fn set_band(&mut self, band: OverlayBand) {
545 self.band = band;
546 }
547
548 /// Whether the surface takes pointer input at all.
549 pub fn set_input(&mut self, input: OverlayInput) {
550 self.input = input;
551 }
552
553 /// What a press outside every floated surface delivers here.
554 pub fn set_outside_tap(&mut self, outside_tap: OutsideTap) {
555 self.outside_tap = outside_tap;
556 }
557
558 /// Where the surface sits relative to its anchor.
559 pub fn set_placement(&mut self, placement: OverlayPlacement) {
560 self.placement = placement;
561 }
562
563 /// What the surface is placed against.
564 pub fn set_anchor(&mut self, anchor: OverlayAnchor) {
565 self.anchor = anchor;
566 }
567
568 /// Mount, reconcile or drop the floated view — the owner's `View::rebuild`
569 /// half.
570 ///
571 /// `prev`/`next` are the previous and current frame's overlay views, in the
572 /// shape [`rebuild_child`](crate::authoring::rebuild_child) itself takes: a
573 /// `None` → `Some` transition builds the pod, `Some` → `Some` reconciles it
574 /// in place (so a kept-open surface keeps its own widget state), `Some` →
575 /// `None` tears it down, and `None` → `None` does nothing. An owner that
576 /// mounts a view it builds itself (from a process-global builder, say)
577 /// keeps the previous one and hands both in.
578 ///
579 /// Dropping the pod also drops any capture it held: the widget that was
580 /// mid-gesture no longer exists, so there is nothing to unwind and nothing
581 /// to route follow-ups to.
582 pub fn rebuild(
583 &mut self,
584 prev: Option<&AnyView<PodState>>,
585 next: Option<&AnyView<PodState>>,
586 ctx: &mut BuildCtx<'_>,
587 ) -> ChangeFlags {
588 match (prev, next) {
589 (Some(prev), Some(next)) => match &self.pod {
590 Some(pod) => {
591 let mut pod = pod.borrow_mut();
592 crate::authoring::rebuild_child(prev, next, &mut pod, ctx)
593 }
594 // The view stayed mounted but the pod did not: build a fresh
595 // one rather than route into nothing.
596 None => self.mount(next, ctx),
597 },
598 (None, Some(next)) => {
599 // Nothing to reconcile against — an existing pod here belongs to
600 // a view the owner no longer has, so it is replaced rather than
601 // diffed (a torn-down widget's state dies with it either way).
602 self.drop_pod();
603 self.mount(next, ctx)
604 }
605 (Some(prev), None) => match self.pod.take() {
606 Some(pod) => {
607 {
608 let mut pod = pod.borrow_mut();
609 crate::authoring::teardown_child(prev, &mut pod, ctx);
610 }
611 self.captured = false;
612 ChangeFlags::LAYOUT
613 }
614 None => ChangeFlags::NONE,
615 },
616 (None, None) => {
617 if self.pod.is_some() {
618 self.drop_pod();
619 ChangeFlags::LAYOUT
620 } else {
621 ChangeFlags::NONE
622 }
623 }
624 }
625 }
626
627 /// Build `view` into a fresh pod, replacing whatever was mounted.
628 fn mount(&mut self, view: &AnyView<PodState>, ctx: &mut BuildCtx<'_>) -> ChangeFlags {
629 let pod = crate::authoring::build_child(view, ctx);
630 self.pod = Some(Rc::new(RefCell::new(pod)));
631 self.captured = false;
632 ChangeFlags::LAYOUT
633 }
634
635 /// Drop the pod with no view to tear it down through — the recovery arm for
636 /// a slot whose mounted view vanished without one.
637 fn drop_pod(&mut self) {
638 self.pod = None;
639 self.captured = false;
640 }
641
642 /// Lay the pod out against the window — the owner's `Widget::layout` half.
643 ///
644 /// Loose constraints against
645 /// [`LayoutCtx::window_size`](frust_core::LayoutCtx::window_size), never the
646 /// owner's own `bc`: the surface escapes the owner's box, so the owner's
647 /// constraints say nothing about how much room it has. The pod's own origin
648 /// stays [`Point::ZERO`] — the root paints it at the registered rect's
649 /// origin and adds the pod's origin on top, and routing tests the registered
650 /// rect alone, so any other value would desynchronize paint from hit test.
651 pub fn layout(&mut self, ctx: &mut LayoutCtx) {
652 self.window = ctx.window_size();
653 if let Some(pod) = &self.pod {
654 let bc = BoxConstraints::loose(self.window);
655 let mut pod = pod.borrow_mut();
656 pod.layout_child(ctx, &bc);
657 pod.set_origin(Point::ZERO);
658 }
659 }
660
661 /// Place and register the pod — the owner's `Widget::paint` half.
662 ///
663 /// Computes the anchor rect in window space from
664 /// [`PaintCtx::origin`](frust_core::PaintCtx::origin) (plus the local rect,
665 /// for [`OverlayAnchor::Rect`]), places the pod against it with [`place`],
666 /// records the result and hands the root a registration. **It paints
667 /// nothing**: an owner that also painted the pod would draw the surface
668 /// twice, once clipped in place and once floated.
669 ///
670 /// Registration is per paint pass, so a surface stays alive exactly while
671 /// its owner keeps painting — an owner that is culled, unmounted or simply
672 /// stops registering disappears from the routing table after the next paint
673 /// with nothing to unregister.
674 pub fn paint(&mut self, ctx: &mut PaintCtx, owner_size: Size) {
675 let Some(pod) = &self.pod else {
676 return;
677 };
678 self.owner_origin = ctx.origin();
679 let anchor = match self.anchor {
680 OverlayAnchor::Owner => Rect::from_origin_size(ctx.origin(), owner_size),
681 OverlayAnchor::Rect(local) => local + ctx.origin().to_vec2(),
682 };
683 let area = Rect::from_origin_size(Point::ZERO, self.window);
684 let content = pod.borrow().size();
685 self.window_rect = place(anchor, content, area, self.placement);
686 ctx.register_overlay(OverlayEntry {
687 key: self.key,
688 band: self.band,
689 input: self.input,
690 outside_tap: self.outside_tap,
691 window_rect: self.window_rect,
692 pod: Rc::clone(pod),
693 insets: ctx.window_insets(),
694 });
695 }
696
697 /// Route an event into the surface over a **substituted** state — the
698 /// owner's `Widget::event` half for a pod whose `PodState` is not the
699 /// ambient application state (a `()`-typed, framework-built surface).
700 ///
701 /// `Some(_)` means the slot owned the event and the owner must not route it
702 /// on; `None` means it belongs to the owner's ordinary routing.
703 ///
704 /// # What crosses the substitution
705 ///
706 /// The pod runs over a fresh [`EventCtx`] built on `state`, exactly as a
707 /// component boundary runs its subtree over its own local state, and the
708 /// results are mirrored back onto the owner's context: a redraw request, a
709 /// pointer capture, and a focus claim or release (observed through the pod's
710 /// own recorded link). A published IME surface and a hover claim do **not**
711 /// cross — an IME publish has no route back through a substituted context,
712 /// and an overlay event is a broadcast, which records no hover anywhere. A
713 /// surface that needs either is built over the ambient state instead (see
714 /// [`event_ambient`](Self::event_ambient)).
715 ///
716 /// Edit commands do cross, by a different road: they ride a pass-scoped
717 /// queue rather than the context, so a pod that calls
718 /// [`EventCtx::dispatch_edit_command`](frust_core::EventCtx::dispatch_edit_command)
719 /// is drained by the owner's
720 /// [`EventCtx::take_edit_commands`](frust_core::EventCtx::take_edit_commands)
721 /// in the same pass.
722 pub fn event(
723 &mut self,
724 ctx: &mut EventCtx<'_>,
725 event: &InputEvent,
726 state: &mut PodState,
727 ) -> Option<EventResult> {
728 let substitute: &mut dyn Any = state;
729 self.route(ctx, event, Some(substitute))
730 }
731
732 /// Route an event into the surface over the **ambient** application state —
733 /// the owner's `Widget::event` half for a pod built over the same state the
734 /// owner itself is diffed against.
735 ///
736 /// The pod is dispatched through the owner's own [`EventCtx`], so
737 /// everything a child normally bubbles (redraw, capture, focus, a published
738 /// IME surface) reaches the root unchanged, and the pod's callbacks reach
739 /// the same application state every other widget sees. This is the route
740 /// [`overlay_portal`] takes.
741 pub fn event_ambient(
742 &mut self,
743 ctx: &mut EventCtx<'_>,
744 event: &InputEvent,
745 ) -> Option<EventResult> {
746 self.route(ctx, event, None)
747 }
748
749 /// The shared body of [`event`](Self::event)/
750 /// [`event_ambient`](Self::event_ambient): decide whether this event belongs
751 /// to the surface and, if it does, translate it into pod space and forward
752 /// it.
753 fn route(
754 &mut self,
755 ctx: &mut EventCtx<'_>,
756 event: &InputEvent,
757 substitute: Option<&mut dyn Any>,
758 ) -> Option<EventResult> {
759 // A closed slot owns nothing: every event belongs to the owner.
760 self.pod.as_ref()?;
761 let origin = self.window_rect.origin().to_vec2();
762 match event {
763 // A floated surface's own input, broadcast to the whole tree so it
764 // reaches this owner wherever it sits. The key comparison is the
765 // entire addressing mechanism: another owner's surface falls
766 // through untouched.
767 InputEvent::Overlay(overlay) if overlay.key == self.key => {
768 match &overlay.kind {
769 OverlayEventKind::Pointer(pointer) => {
770 let local = InputEvent::Pointer(PointerEvent {
771 position: pointer.position - origin,
772 ..*pointer
773 });
774 self.forward(ctx, &local, substitute);
775 }
776 OverlayEventKind::Scroll { position, delta } => {
777 let local = InputEvent::Scroll {
778 position: *position - origin,
779 delta: *delta,
780 };
781 self.forward(ctx, &local, substitute);
782 }
783 // A scale gesture is routed on the same terms as a scroll:
784 // only its focal point needs lifting into surface space.
785 OverlayEventKind::Scale {
786 focal,
787 phase,
788 scale_delta,
789 velocity,
790 } => {
791 let local = InputEvent::Scale(frust_core::event::ScaleEvent {
792 phase: *phase,
793 scale_delta: *scale_delta,
794 focal: *focal - origin,
795 velocity: *velocity,
796 });
797 self.forward(ctx, &local, substitute);
798 }
799 // The press landed on nothing floated: the surface never saw
800 // it, so nothing is forwarded — the owner reads the
801 // notification and decides whether to close.
802 OverlayEventKind::OutsideDown => self.outside_down_pending = true,
803 }
804 // A broadcast is never consumed, whatever the pod returned.
805 Some(EventResult::Ignored)
806 }
807 // A gesture that began inside the surface: the capture it opened
808 // short-circuits the root's overlay pre-pass, so its follow-ups
809 // arrive here as ordinary pointer events in the OWNER's local space
810 // and have to be lifted back into window space first.
811 InputEvent::Pointer(pointer) if self.captured => {
812 let local = InputEvent::Pointer(PointerEvent {
813 position: pointer.position + self.owner_origin.to_vec2() - origin,
814 ..*pointer
815 });
816 // The release that ends the gesture is handled where the pod
817 // is dispatched, so it lands whichever door the event came in
818 // by — this one, or the broadcast arm above.
819 Some(self.forward(ctx, &local, substitute))
820 }
821 // Keyboard, IME and the clipboard verbs: focus-routed, so they only
822 // arrive here at all because the owner is on the recorded focus
823 // chain — and they belong to the surface exactly when the surface is
824 // what claimed focus.
825 event if event.is_focus_routed() && self.pod_has_focus() => {
826 Some(self.forward(ctx, event, substitute))
827 }
828 _ => None,
829 }
830 }
831
832 /// Dispatch `local` (already in pod space) into the pod, updating the
833 /// slot's own capture bookkeeping and, for a substituted state, mirroring
834 /// the pod's results onto the owner's context.
835 fn forward(
836 &mut self,
837 ctx: &mut EventCtx<'_>,
838 local: &InputEvent,
839 substitute: Option<&mut dyn Any>,
840 ) -> EventResult {
841 let Some(pod) = self.pod.clone() else {
842 return EventResult::Ignored;
843 };
844 let mut pod = pod.borrow_mut();
845 let was_active = pod.is_active();
846 let was_focused = pod.is_focused();
847 let result = match substitute {
848 // The ambient route: the pod is a child like any other, and
849 // `event_child` bubbles its redraw/capture/focus/IME flags into the
850 // owner's context on its own.
851 None => pod.event_child(ctx, local),
852 Some(state) => {
853 let (result, needs_redraw) = {
854 let mut inner =
855 EventCtx::new(state, self.window_rect.origin(), self.window_rect.size());
856 let result = pod.event_child(&mut inner, local);
857 (result, inner.needs_redraw())
858 };
859 if needs_redraw {
860 ctx.request_redraw();
861 }
862 // Capture and focus are mirrored through the pod's own recorded
863 // links rather than the substituted context's flags: the pod
864 // records exactly what its widget asked for, and reading it here
865 // keeps one mirror instead of two.
866 if pod.is_active() && !was_active {
867 ctx.capture_pointer();
868 }
869 match (was_focused, pod.is_focused()) {
870 (false, true) => ctx.request_focus(),
871 (true, false) => ctx.release_focus(),
872 _ => {}
873 }
874 result
875 }
876 };
877 // Give the link the lifetime every other container gives a captured
878 // child. `event_child` only ever sets it, and this is the one dispatch
879 // path that does not run through a routing helper, so the release has
880 // to happen here or never — and until it does, the two `was_active`
881 // edges above are edges into a latch that never falls.
882 if was_active && releases_capture(local) {
883 pod.set_active(false);
884 }
885 self.captured = pod.is_active();
886 result
887 }
888}
889
890// ---------------------------------------------------------------------------
891// The declarative portal
892// ---------------------------------------------------------------------------
893
894/// Float `overlay` above the whole app, anchored to `child`'s bounds — the
895/// declarative half of the portal.
896///
897/// The child is laid out, painted and routed exactly as it would be without the
898/// wrapper: the portal adds nothing to its geometry and consumes none of its
899/// input. The overlay is mounted while [`OverlayPortalView::overlay`] is
900/// `Some`, which is also how an exit animation is expressed — keep handing the
901/// same view in while the surface ramps out, and hand `None` once it has.
902///
903/// ```
904/// use frust_core::any;
905/// use frust_widgets::{OverlayPlacement, OverlaySide, overlay_portal, text};
906///
907/// struct App {
908/// hovering: bool,
909/// }
910///
911/// fn tip(state: &mut App) -> impl frust_core::View<App> + use<> {
912/// overlay_portal(text("save"))
913/// .overlay(state.hovering.then(|| any(text("Save the document"))))
914/// .placement(OverlayPlacement::on(OverlaySide::Top))
915/// }
916/// # let _ = tip;
917/// ```
918pub fn overlay_portal<State: 'static, V: View<State>>(child: V) -> OverlayPortalView<State> {
919 OverlayPortalView {
920 child: any(child),
921 overlay: None,
922 placement: OverlayPlacement::default(),
923 band: OverlayBand::Floating,
924 input: OverlayInput::Interactive,
925 outside_tap: OutsideTap::Ignore,
926 on_outside_tap: None,
927 preserve_focus: false,
928 }
929}
930
931/// The light-dismiss callback a portal holds before it is erased.
932type OnOutsideTap<State> = Rc<dyn Fn(&mut State)>;
933
934/// A declarative overlay portal. See [`overlay_portal`].
935pub struct OverlayPortalView<State: 'static> {
936 child: AnyView<State>,
937 overlay: Option<AnyView<State>>,
938 placement: OverlayPlacement,
939 band: OverlayBand,
940 input: OverlayInput,
941 outside_tap: OutsideTap,
942 on_outside_tap: Option<OnOutsideTap<State>>,
943 preserve_focus: bool,
944}
945
946impl<State: 'static> OverlayPortalView<State> {
947 /// The floated surface: `Some` mounts it, `None` drops it after the next
948 /// paint.
949 ///
950 /// The view is diffed against the **same** application state the portal
951 /// itself is, so the surface reads and writes app state exactly like the
952 /// child does — it is a logical child of this call site that happens to be
953 /// painted elsewhere.
954 pub fn overlay(mut self, overlay: Option<AnyView<State>>) -> Self {
955 self.overlay = overlay;
956 self
957 }
958
959 /// Where the surface sits relative to the child's bounds (default: below,
960 /// centred — see [`OverlayPlacement::default`]).
961 pub fn placement(mut self, placement: OverlayPlacement) -> Self {
962 self.placement = placement;
963 self
964 }
965
966 /// Which z-band the surface paints and hit-tests in (default
967 /// [`OverlayBand::Floating`]; a tooltip belongs in
968 /// [`OverlayBand::Tooltip`]).
969 pub fn band(mut self, band: OverlayBand) -> Self {
970 self.band = band;
971 self
972 }
973
974 /// Whether the surface takes pointer input (default
975 /// [`OverlayInput::Interactive`]; explanatory chrome the pointer passes
976 /// through is [`OverlayInput::Transparent`]).
977 pub fn input(mut self, input: OverlayInput) -> Self {
978 self.input = input;
979 self
980 }
981
982 /// What a press landing outside every floated surface delivers here
983 /// (default [`OutsideTap::Ignore`]).
984 pub fn outside_tap(mut self, outside_tap: OutsideTap) -> Self {
985 self.outside_tap = outside_tap;
986 self
987 }
988
989 /// Run `callback` when a press lands outside every floated surface — the
990 /// light-dismiss hook.
991 ///
992 /// Setting it also opts the surface into the notification
993 /// ([`OutsideTap::Notify`] with `consume: true`, the modal shape) unless an
994 /// explicit [`outside_tap`](Self::outside_tap) says otherwise, so the tap
995 /// that dismisses a menu does not also activate what sits under it. Pass
996 /// `OutsideTap::Notify { consume: false }` for the pass-through shape.
997 pub fn on_outside_tap(mut self, callback: impl Fn(&mut State) + 'static) -> Self {
998 self.on_outside_tap = Some(Rc::new(callback));
999 if self.outside_tap == OutsideTap::Ignore {
1000 self.outside_tap = OutsideTap::Notify { consume: true };
1001 }
1002 self
1003 }
1004
1005 /// Keep the child's focus session when the surface claims focus (default
1006 /// `false`).
1007 ///
1008 /// A surface that is *about* the child rather than a place to type — a
1009 /// selection toolbar over a field — must not take the field's focus, or the
1010 /// selection it acts on disappears the moment it is touched. With this set,
1011 /// a focus claim from inside the surface is withdrawn again whenever the
1012 /// child held the focus link, and focus-routed events keep reaching the
1013 /// child. Left `false`, a surface that claims focus gets it and the child's
1014 /// link inside this portal is dropped, which is what a popover containing
1015 /// its own text field wants.
1016 pub fn preserve_focus(mut self, preserve: bool) -> Self {
1017 self.preserve_focus = preserve;
1018 self
1019 }
1020}
1021
1022/// The retained widget for an [`OverlayPortalView`].
1023pub struct OverlayPortalWidget<State: 'static> {
1024 child: ChildPod,
1025 slot: OverlaySlot<State>,
1026 on_outside_tap: Option<ErasedCallback>,
1027 preserve_focus: bool,
1028}
1029
1030impl<State: 'static> OverlayPortalView<State> {
1031 /// Push the view's routing configuration onto the slot — the half of
1032 /// `build`/`rebuild` that is identical in both.
1033 fn configure(&self, slot: &mut OverlaySlot<State>) {
1034 slot.set_placement(self.placement);
1035 slot.set_band(self.band);
1036 slot.set_input(self.input);
1037 slot.set_outside_tap(self.outside_tap);
1038 }
1039}
1040
1041impl<State: 'static> View<State> for OverlayPortalView<State> {
1042 type Element = OverlayPortalWidget<State>;
1043
1044 fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayPortalWidget<State> {
1045 let mut slot = OverlaySlot::new();
1046 self.configure(&mut slot);
1047 slot.rebuild(None, self.overlay.as_ref(), ctx);
1048 OverlayPortalWidget {
1049 child: crate::authoring::build_child(&self.child, ctx),
1050 slot,
1051 on_outside_tap: self.on_outside_tap.as_ref().map(erase_callback),
1052 preserve_focus: self.preserve_focus,
1053 }
1054 }
1055
1056 fn rebuild(
1057 &self,
1058 prev: &Self,
1059 element: &mut OverlayPortalWidget<State>,
1060 ctx: &mut BuildCtx<'_>,
1061 ) -> ChangeFlags {
1062 let mut flags = ChangeFlags::NONE;
1063 if prev.placement != self.placement
1064 || prev.band != self.band
1065 || prev.input != self.input
1066 || prev.outside_tap != self.outside_tap
1067 {
1068 // Placement and the routing fields are re-read from the slot on the
1069 // next paint, which is also when the new rect is registered.
1070 flags |= ChangeFlags::PAINT;
1071 }
1072 self.configure(&mut element.slot);
1073 element.preserve_focus = self.preserve_focus;
1074 // Closures are not comparable, so the adapter is reinstalled
1075 // unconditionally — it is cheap.
1076 element.on_outside_tap = self.on_outside_tap.as_ref().map(erase_callback);
1077 flags |= element
1078 .slot
1079 .rebuild(prev.overlay.as_ref(), self.overlay.as_ref(), ctx);
1080 flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
1081 flags
1082 }
1083
1084 fn teardown(&self, element: &mut OverlayPortalWidget<State>, ctx: &mut BuildCtx<'_>) {
1085 element.slot.rebuild(self.overlay.as_ref(), None, ctx);
1086 crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
1087 }
1088}
1089
1090impl<State: 'static> Widget for OverlayPortalWidget<State> {
1091 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1092 let size = self.child.layout_child(ctx, bc);
1093 self.child.set_origin(Point::ZERO);
1094 // The floated pod is sized against the window, not against `bc` — it
1095 // escapes this widget's box entirely.
1096 self.slot.layout(ctx);
1097 bc.constrain(size)
1098 }
1099
1100 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1101 self.child.paint_child(ctx, scene);
1102 // Registered, never painted here: the root paints it after the whole
1103 // main tree, which is what puts it above a later sibling.
1104 let size = ctx.size();
1105 self.slot.paint(ctx, size);
1106 }
1107
1108 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1109 let child_focused = self.child.holds_live_focus();
1110 // A focus-routed event belongs to the child whenever the child holds the
1111 // live link. Both links can be live at once only through
1112 // `preserve_focus`, which deliberately re-records the child's against the
1113 // same session it hands back; otherwise exactly one of them is, and this
1114 // check simply prefers the main tree when both are.
1115 if !(event.is_focus_routed() && child_focused) {
1116 let pod_focused_before = self.slot.pod_has_focus();
1117 if let Some(result) = self.slot.event_ambient(ctx, event) {
1118 // Drained unconditionally: a notification with no callback
1119 // installed is still spent, not left standing for a later pass.
1120 if self.slot.take_outside_down()
1121 && let Some(callback) = &mut self.on_outside_tap
1122 {
1123 callback(ctx);
1124 ctx.request_redraw();
1125 }
1126 if !pod_focused_before && self.slot.pod_has_focus() {
1127 if self.preserve_focus && child_focused {
1128 // Hand the session back to the child: the surface's own
1129 // claim is withdrawn, and re-asserting the request keeps
1130 // every ancestor's recorded chain pointing here.
1131 //
1132 // The child's own link is re-recorded rather than left
1133 // alone. The surface's claim opened a new focus session,
1134 // and a link recorded against the session before it is
1135 // not a link on this one — so handing the session back
1136 // means saying so on the child's own record, not merely
1137 // not clearing it.
1138 self.slot.withdraw_pod_focus();
1139 self.child.set_focused(true);
1140 ctx.request_focus();
1141 } else {
1142 // The surface took the session, so the child's link
1143 // inside this portal is stale — an overlay press never
1144 // reaches the root's own blur rule to clear it.
1145 self.child.set_focused(false);
1146 }
1147 }
1148 return result;
1149 }
1150 }
1151 route_event_single(&mut self.child, ctx, event)
1152 }
1153
1154 fn semantics(&self, ctx: &mut SemanticsCtx) {
1155 // The child only: a floated pod's nodes would attach at this widget's
1156 // position rather than the surface's (see the module docs).
1157 self.child.semantics_child(ctx);
1158 }
1159
1160 crate::authoring::visit_children!(child);
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165 use super::*;
1166 use crate::test_support::RecordingScene;
1167 use crate::{Column, SizedBox, Stack, StackView, scroll_view};
1168 use frust_core::{
1169 EditCommand, FrameTime, Key, KeyEvent, Modifiers, NamedKey, PointerButton, PointerPhase,
1170 RenderRoot, ScrollDelta,
1171 };
1172 use peniko::Color;
1173
1174 // -----------------------------------------------------------------------
1175 // Placement
1176 // -----------------------------------------------------------------------
1177
1178 const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 600.0);
1179
1180 /// Placement with the padding switched off, so a geometry assertion reads
1181 /// against the raw area rather than the inset one.
1182 fn bare() -> OverlayPlacement {
1183 OverlayPlacement::default().padding(0.0)
1184 }
1185
1186 fn anchor_rect(x: f64, y: f64, w: f64, h: f64) -> Rect {
1187 Rect::from_origin_size(Point::new(x, y), Size::new(w, h))
1188 }
1189
1190 #[test]
1191 fn the_default_placement_is_below_the_anchor_centred_at_the_neutral_gap() {
1192 let p = OverlayPlacement::default();
1193 assert_eq!(p.side, OverlaySide::Bottom);
1194 assert_eq!(p.align, OverlayAlign::Center);
1195 assert_eq!(p.offset, DEFAULT_OFFSET);
1196 assert_eq!(p.padding, DEFAULT_PADDING);
1197 assert!(p.flip && p.clamp);
1198 }
1199
1200 #[test]
1201 fn each_side_offsets_off_its_own_edge() {
1202 let a = anchor_rect(100.0, 200.0, 80.0, 40.0);
1203 let content = Size::new(120.0, 60.0);
1204 // The raw side placement, with the collision passes off: `Left` would
1205 // otherwise flip (a 120px panel does not fit in the 100px to the
1206 // anchor's left), which the flip test below covers on its own.
1207 let raw = |side| OverlayPlacement::on(side).flip(false).clamp(false);
1208 let bottom = place(a, content, AREA, raw(OverlaySide::Bottom));
1209 assert_eq!(bottom.y0, a.y1 + DEFAULT_OFFSET);
1210 let top = place(a, content, AREA, raw(OverlaySide::Top));
1211 assert_eq!(top.y1, a.y0 - DEFAULT_OFFSET);
1212 let right = place(a, content, AREA, raw(OverlaySide::Right));
1213 assert_eq!(right.x0, a.x1 + DEFAULT_OFFSET);
1214 let left = place(a, content, AREA, raw(OverlaySide::Left));
1215 assert_eq!(left.x1, a.x0 - DEFAULT_OFFSET);
1216 // Sizes are never altered by placement.
1217 for r in [bottom, top, right, left] {
1218 assert_eq!(r.size(), content);
1219 }
1220 assert!(OverlaySide::Top.is_vertical() && !OverlaySide::Left.is_vertical());
1221 assert_eq!(OverlaySide::Top.opposite(), OverlaySide::Bottom);
1222 assert_eq!(OverlaySide::Left.opposite(), OverlaySide::Right);
1223 }
1224
1225 #[test]
1226 fn align_lines_up_leading_center_or_trailing_edges() {
1227 let a = anchor_rect(100.0, 200.0, 80.0, 40.0);
1228 let content = Size::new(120.0, 60.0);
1229 let at = |side, align| place(a, content, AREA, OverlayPlacement::on(side).align(align));
1230 assert_eq!(at(OverlaySide::Bottom, OverlayAlign::Start).x0, a.x0);
1231 assert_eq!(at(OverlaySide::Bottom, OverlayAlign::End).x1, a.x1);
1232 assert_eq!(
1233 at(OverlaySide::Bottom, OverlayAlign::Center).center().x,
1234 a.center().x
1235 );
1236
1237 // The same three on a horizontal side act on the vertical axis.
1238 assert_eq!(at(OverlaySide::Right, OverlayAlign::Start).y0, a.y0);
1239 assert_eq!(at(OverlaySide::Right, OverlayAlign::End).y1, a.y1);
1240 assert_eq!(
1241 at(OverlaySide::Right, OverlayAlign::Center).center().y,
1242 a.center().y
1243 );
1244 }
1245
1246 #[test]
1247 fn a_side_that_does_not_fit_flips_to_the_opposite_one() {
1248 // An anchor near the bottom edge: `Bottom` overflows, `Top` fits.
1249 let a = anchor_rect(100.0, 560.0, 80.0, 20.0);
1250 let content = Size::new(120.0, 100.0);
1251 let flipped = place(a, content, AREA, bare());
1252 assert_eq!(
1253 flipped.y1,
1254 a.y0 - DEFAULT_OFFSET,
1255 "flipped above the anchor"
1256 );
1257
1258 // With the flip disabled it stays below and only the clamp moves it.
1259 let pinned = place(a, content, AREA, bare().flip(false));
1260 assert_eq!(pinned.y1, AREA.y1, "clamped, not flipped");
1261
1262 // Neither side fits: the preferred one is kept (and clamped).
1263 let tall = Size::new(120.0, 590.0);
1264 let kept = place(a, tall, AREA, bare());
1265 assert_eq!(kept.y1, AREA.y1);
1266 }
1267
1268 #[test]
1269 fn each_side_flips_at_the_edge_it_would_overflow() {
1270 let content = Size::new(120.0, 100.0);
1271 // Top edge: a `Top` placement flips down.
1272 let high = anchor_rect(100.0, 10.0, 80.0, 20.0);
1273 let down = place(high, content, AREA, OverlayPlacement::on(OverlaySide::Top));
1274 assert_eq!(down.y0, high.y1 + DEFAULT_OFFSET);
1275 // Left edge: a `Left` placement flips right.
1276 let leading = anchor_rect(10.0, 200.0, 20.0, 20.0);
1277 let right = place(
1278 leading,
1279 content,
1280 AREA,
1281 OverlayPlacement::on(OverlaySide::Left),
1282 );
1283 assert_eq!(right.x0, leading.x1 + DEFAULT_OFFSET);
1284 // Right edge: a `Right` placement flips left.
1285 let trailing = anchor_rect(370.0, 200.0, 20.0, 20.0);
1286 let left = place(
1287 trailing,
1288 content,
1289 AREA,
1290 OverlayPlacement::on(OverlaySide::Right),
1291 );
1292 assert_eq!(left.x1, trailing.x0 - DEFAULT_OFFSET);
1293 }
1294
1295 #[test]
1296 fn clamping_shifts_the_rect_back_inside_and_can_be_turned_off() {
1297 // An anchor at the right edge, centre-aligned: the panel overflows.
1298 let a = anchor_rect(380.0, 100.0, 20.0, 20.0);
1299 let content = Size::new(200.0, 50.0);
1300 let clamped = place(a, content, AREA, bare());
1301 assert_eq!(clamped.x1, AREA.x1);
1302 assert_eq!(
1303 clamped.y0,
1304 a.y1 + DEFAULT_OFFSET,
1305 "only the cross axis moved"
1306 );
1307
1308 let free = place(a, content, AREA, bare().clamp(false));
1309 assert!(free.x1 > AREA.x1, "unclamped placement may overflow");
1310
1311 // Content wider than the area pins to the leading edge (the start-edge
1312 // rule), not the trailing one.
1313 let huge = Size::new(600.0, 50.0);
1314 let pinned = place(a, huge, AREA, bare());
1315 assert_eq!(pinned.x0, AREA.x0);
1316 }
1317
1318 #[test]
1319 fn the_clamp_keeps_the_padding_off_every_edge() {
1320 let content = Size::new(200.0, 50.0);
1321 // Trailing overflow lands `DEFAULT_PADDING` short of the area edge.
1322 let trailing = place(
1323 anchor_rect(380.0, 100.0, 20.0, 20.0),
1324 content,
1325 AREA,
1326 OverlayPlacement::default(),
1327 );
1328 assert_eq!(trailing.x1, AREA.x1 - DEFAULT_PADDING);
1329 // …and so does a leading one.
1330 let leading = place(
1331 anchor_rect(0.0, 100.0, 20.0, 20.0),
1332 content,
1333 AREA,
1334 OverlayPlacement::default(),
1335 );
1336 assert_eq!(leading.x0, AREA.x0 + DEFAULT_PADDING);
1337 // The bottom edge is the same rule on the other axis.
1338 let low = place(
1339 anchor_rect(100.0, 560.0, 20.0, 20.0),
1340 Size::new(100.0, 300.0),
1341 AREA,
1342 OverlayPlacement::default().flip(false),
1343 );
1344 assert_eq!(low.y1, AREA.y1 - DEFAULT_PADDING);
1345 }
1346
1347 #[test]
1348 fn a_padding_larger_than_the_area_falls_back_to_the_area_itself() {
1349 // A window narrower than twice the padding still has to place its
1350 // surface somewhere; the inset would invert, so it is dropped.
1351 let tiny = Rect::new(0.0, 0.0, 10.0, 10.0);
1352 let rect = place(
1353 Rect::ZERO,
1354 Size::new(4.0, 4.0),
1355 tiny,
1356 OverlayPlacement::default().flip(false),
1357 );
1358 assert!(tiny.contains(rect.origin()));
1359 assert_eq!(rect.x0, 0.0, "clamped to the un-inset area's own edge");
1360 }
1361
1362 #[test]
1363 fn a_degenerate_anchor_places_against_that_point() {
1364 let content = Size::new(80.0, 40.0);
1365 let rect = place(Rect::ZERO, content, AREA, OverlayPlacement::default());
1366 assert_eq!(
1367 rect.x0,
1368 AREA.x0 + DEFAULT_PADDING,
1369 "clamped in from the centred overhang"
1370 );
1371 // The neutral gap is smaller than the padding, so the same clamp holds
1372 // the surface off the top edge as well.
1373 assert_eq!(rect.y0, AREA.y0 + DEFAULT_PADDING);
1374 }
1375
1376 // -----------------------------------------------------------------------
1377 // The portal, driven through a real render root
1378 // -----------------------------------------------------------------------
1379
1380 /// Everything the fixture's widgets record, in the order it happened.
1381 type Log = Rc<RefCell<Vec<String>>>;
1382
1383 const WINDOW: Size = Size::new(400.0, 600.0);
1384
1385 /// Where the `Plain` fixture's surface lands: the top-left corner of the
1386 /// padded field, because a window-sized anchor fits its content on neither
1387 /// side and the clamp takes over.
1388 const POD: Rect = Rect::new(8.0, 8.0, 88.0, 38.0);
1389
1390 /// What the floated content does when it is pressed.
1391 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1392 enum Reaction {
1393 #[default]
1394 Nothing,
1395 Capture,
1396 Focus,
1397 }
1398
1399 /// Which tree the fixture builds.
1400 #[derive(Clone, Copy, PartialEq, Eq)]
1401 enum Shape {
1402 /// A window-filling portal child with a later, window-filling sibling.
1403 Plain,
1404 /// A small portal child partway down a scrollable column.
1405 Scrolled,
1406 /// The portal beside a focus-taking field, both inside one `Column`, so
1407 /// the container reconciling them is the ordinary
1408 /// [`route_event`](crate::authoring::route_event) one and the field is a
1409 /// SIBLING of the portal rather than its child — the arrangement in
1410 /// which a recorded focus link the container never swept still decides
1411 /// where a focus-routed event goes.
1412 Sibling,
1413 }
1414
1415 #[derive(Clone, Copy)]
1416 struct Cfg {
1417 open: bool,
1418 shape: Shape,
1419 band: OverlayBand,
1420 input: OverlayInput,
1421 /// `None` leaves the policy to `on_outside_tap`'s own default.
1422 outside_tap: Option<OutsideTap>,
1423 preserve_focus: bool,
1424 reaction: Reaction,
1425 placement: OverlayPlacement,
1426 }
1427
1428 /// The corner placement the `Plain` fixture pins its surface with — chosen
1429 /// so the rect is the same however the fixture is configured.
1430 fn corner() -> OverlayPlacement {
1431 OverlayPlacement::on(OverlaySide::Top)
1432 .offset(0.0)
1433 .align(OverlayAlign::Start)
1434 }
1435
1436 impl Default for Cfg {
1437 fn default() -> Self {
1438 Cfg {
1439 open: true,
1440 shape: Shape::Plain,
1441 band: OverlayBand::Floating,
1442 input: OverlayInput::Interactive,
1443 outside_tap: Some(OutsideTap::Ignore),
1444 preserve_focus: false,
1445 reaction: Reaction::Nothing,
1446 placement: corner(),
1447 }
1448 }
1449 }
1450
1451 struct App {
1452 cfg: Cfg,
1453 log: Log,
1454 presses: u32,
1455 outside_taps: u32,
1456 }
1457
1458 /// A recording leaf: paints one rect at its own absolute origin, records
1459 /// every event it receives (positions in its own local space) and reacts to
1460 /// a press the way the fixture asked it to.
1461 struct Probe {
1462 tag: &'static str,
1463 /// `None` fills whatever it is offered.
1464 size: Option<Size>,
1465 reaction: Reaction,
1466 /// Whether its pointer arms report `Handled` — a `false` leaf lets a
1467 /// press fall through to the child painted under it.
1468 handles: bool,
1469 log: Log,
1470 }
1471
1472 struct ProbeWidget {
1473 tag: &'static str,
1474 size: Option<Size>,
1475 reaction: Reaction,
1476 handles: bool,
1477 log: Log,
1478 }
1479
1480 impl View<App> for Probe {
1481 type Element = ProbeWidget;
1482 fn build(&self, _ctx: &mut BuildCtx<'_>) -> ProbeWidget {
1483 ProbeWidget {
1484 tag: self.tag,
1485 size: self.size,
1486 reaction: self.reaction,
1487 handles: self.handles,
1488 log: Rc::clone(&self.log),
1489 }
1490 }
1491 fn rebuild(
1492 &self,
1493 _prev: &Self,
1494 element: &mut ProbeWidget,
1495 _ctx: &mut BuildCtx<'_>,
1496 ) -> ChangeFlags {
1497 element.size = self.size;
1498 element.reaction = self.reaction;
1499 element.handles = self.handles;
1500 element.log = Rc::clone(&self.log);
1501 ChangeFlags::NONE
1502 }
1503 }
1504
1505 impl Widget for ProbeWidget {
1506 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1507 bc.constrain(self.size.unwrap_or_else(|| bc.max()))
1508 }
1509 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1510 scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
1511 }
1512 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1513 match event {
1514 InputEvent::Pointer(p) => {
1515 self.log.borrow_mut().push(format!(
1516 "{}:{:?}@{},{}",
1517 self.tag, p.phase, p.position.x, p.position.y
1518 ));
1519 if p.phase == PointerPhase::Down {
1520 ctx.state_mut::<App>().presses += 1;
1521 match self.reaction {
1522 // A press this leaf only records: the commonest
1523 // shape, and the one that must not disturb focus.
1524 Reaction::Nothing => {}
1525 Reaction::Capture => ctx.capture_pointer(),
1526 Reaction::Focus => ctx.request_focus(),
1527 }
1528 }
1529 if self.handles {
1530 EventResult::Handled
1531 } else {
1532 EventResult::Ignored
1533 }
1534 }
1535 InputEvent::Key(_) => {
1536 self.log.borrow_mut().push(format!(
1537 "{}:key focus={}",
1538 self.tag,
1539 ctx.has_focus()
1540 ));
1541 EventResult::Handled
1542 }
1543 _ => EventResult::Ignored,
1544 }
1545 }
1546 }
1547
1548 /// The fixture's whole view tree, rebuilt from the app state every frame so
1549 /// a test can reconfigure it between frames.
1550 fn logic(state: &mut App) -> StackView<App> {
1551 let cfg = state.cfg;
1552 let log = Rc::clone(&state.log);
1553 let pod = cfg.open.then(|| {
1554 any(Probe {
1555 tag: "pod",
1556 size: Some(Size::new(80.0, 30.0)),
1557 reaction: cfg.reaction,
1558 handles: true,
1559 log: Rc::clone(&log),
1560 })
1561 });
1562 // The child claims focus on a press, so it stands in for the focused
1563 // field a surface is opened over.
1564 let child = Probe {
1565 tag: "child",
1566 size: match cfg.shape {
1567 Shape::Plain => None,
1568 Shape::Scrolled => Some(Size::new(120.0, 40.0)),
1569 Shape::Sibling => Some(Size::new(400.0, 50.0)),
1570 },
1571 reaction: Reaction::Focus,
1572 handles: true,
1573 log: Rc::clone(&log),
1574 };
1575 let mut portal = overlay_portal(child)
1576 .overlay(pod)
1577 .placement(cfg.placement)
1578 .band(cfg.band)
1579 .input(cfg.input)
1580 .preserve_focus(cfg.preserve_focus)
1581 .on_outside_tap(|state: &mut App| state.outside_taps += 1);
1582 if let Some(policy) = cfg.outside_tap {
1583 portal = portal.outside_tap(policy);
1584 }
1585 match cfg.shape {
1586 Shape::Plain => Stack(vec![
1587 any(portal),
1588 // Painted after the portal and covering it, so "the pod paints
1589 // above a later sibling" is a real question. It handles nothing,
1590 // so a press falls through to the portal's own child.
1591 any(Probe {
1592 tag: "sib",
1593 size: None,
1594 reaction: Reaction::Nothing,
1595 handles: false,
1596 log,
1597 }),
1598 ]),
1599 Shape::Scrolled => Stack(vec![any(scroll_view(Column(vec![
1600 any(SizedBox(Some(400.0), Some(200.0))),
1601 any(portal),
1602 any(SizedBox(Some(400.0), Some(1000.0))),
1603 ])))]),
1604 // The field first, the portal second: a focus-routed event walking
1605 // the children in order meets the field's link before the portal's.
1606 Shape::Sibling => Stack(vec![any(Column(vec![
1607 any(Probe {
1608 tag: "field",
1609 size: Some(Size::new(400.0, 50.0)),
1610 reaction: Reaction::Focus,
1611 handles: true,
1612 log,
1613 }),
1614 any(portal),
1615 ]))]),
1616 }
1617 }
1618
1619 struct Harness {
1620 root: RenderRoot<App, StackView<App>>,
1621 state: App,
1622 clock_ms: f64,
1623 }
1624
1625 impl Harness {
1626 fn new(cfg: Cfg) -> Self {
1627 Harness {
1628 root: RenderRoot::new(),
1629 state: App {
1630 cfg,
1631 log: Rc::new(RefCell::new(Vec::new())),
1632 presses: 0,
1633 outside_taps: 0,
1634 },
1635 clock_ms: 0.0,
1636 }
1637 }
1638
1639 /// One whole frame: rebuild, layout, paint — returning what was painted,
1640 /// in paint order.
1641 fn frame(&mut self) -> RecordingScene {
1642 let mut build: fn(&mut App) -> StackView<App> = logic;
1643 self.root.rebuild(&mut build, &mut self.state);
1644 self.root.layout(WINDOW);
1645 self.clock_ms += 16.0;
1646 let mut scene = RecordingScene::default();
1647 self.root.paint(
1648 &mut scene,
1649 FrameTime::from_nanos((self.clock_ms * 1_000_000.0) as u64),
1650 );
1651 scene
1652 }
1653
1654 fn pointer(&mut self, phase: PointerPhase, x: f64, y: f64) {
1655 self.root.event(
1656 &mut self.state,
1657 &InputEvent::Pointer(PointerEvent {
1658 phase,
1659 position: Point::new(x, y),
1660 button: PointerButton::Primary,
1661 }),
1662 );
1663 }
1664
1665 fn down(&mut self, x: f64, y: f64) {
1666 self.pointer(PointerPhase::Down, x, y);
1667 }
1668
1669 fn key(&mut self) {
1670 self.root.event(
1671 &mut self.state,
1672 &InputEvent::Key(KeyEvent {
1673 key: Key::Named(NamedKey::ArrowLeft),
1674 modifiers: Modifiers::default(),
1675 repeat: false,
1676 }),
1677 );
1678 }
1679
1680 fn log(&self) -> Vec<String> {
1681 self.state.log.borrow().clone()
1682 }
1683
1684 fn clear_log(&mut self) {
1685 self.state.log.borrow_mut().clear();
1686 }
1687 }
1688
1689 /// The rects a scene recorded, so a paint-order assertion reads as geometry.
1690 fn rects(scene: &RecordingScene) -> Vec<Rect> {
1691 scene
1692 .rects
1693 .iter()
1694 .map(|(origin, size)| Rect::from_origin_size(*origin, *size))
1695 .collect()
1696 }
1697
1698 #[test]
1699 fn the_overlay_pod_paints_above_a_later_sibling() {
1700 let mut h = Harness::new(Cfg::default());
1701 let scene = h.frame();
1702 let painted = rects(&scene);
1703 assert_eq!(
1704 painted.last().copied(),
1705 Some(POD),
1706 "the floated pod paints after the whole main tree, not in place: {painted:?}"
1707 );
1708 // …and it really is covered in the main tree: the sibling painted over
1709 // the same pixels one step earlier.
1710 let sibling = painted[painted.len() - 2];
1711 assert_eq!(sibling, Rect::from_origin_size(Point::ZERO, WINDOW));
1712 }
1713
1714 #[test]
1715 fn a_press_inside_the_surface_reaches_the_pod_in_its_own_space() {
1716 let mut h = Harness::new(Cfg::default());
1717 h.frame();
1718 h.clear_log();
1719 h.down(40.0, 20.0);
1720 assert_eq!(
1721 h.log(),
1722 vec!["pod:Down@32,12".to_string()],
1723 "the press is delivered in pod space (window minus the placed origin)"
1724 );
1725 assert_eq!(
1726 h.state.presses, 1,
1727 "the pod's own callback ran on app state"
1728 );
1729 }
1730
1731 #[test]
1732 fn a_transparent_tooltip_surface_never_receives_input() {
1733 let mut h = Harness::new(Cfg {
1734 band: OverlayBand::Tooltip,
1735 input: OverlayInput::Transparent,
1736 ..Cfg::default()
1737 });
1738 let scene = h.frame();
1739 assert_eq!(
1740 rects(&scene).last().copied(),
1741 Some(POD),
1742 "a transparent surface is still painted above everything"
1743 );
1744 h.clear_log();
1745 h.down(40.0, 20.0);
1746 assert_eq!(
1747 h.log(),
1748 vec!["sib:Down@40,20".to_string(), "child:Down@40,20".to_string()],
1749 "the pointer passes through to the main tree, topmost sibling first"
1750 );
1751 }
1752
1753 #[test]
1754 fn a_drag_begun_on_the_surface_continues_into_it_through_the_capture() {
1755 // Deliberately the scrolled tree: the owner sits 200px down, so the
1756 // follow-ups — which arrive in the OWNER's local space, not the
1757 // window's — are wrong by exactly that much unless they are lifted.
1758 let mut h = Harness::new(Cfg {
1759 shape: Shape::Scrolled,
1760 placement: OverlayPlacement::default(),
1761 reaction: Reaction::Capture,
1762 ..Cfg::default()
1763 });
1764 h.frame();
1765 h.clear_log();
1766 h.down(30.0, 250.0);
1767 assert!(
1768 h.root.is_pointer_captured(),
1769 "a capture claimed from inside the surface is honoured"
1770 );
1771 // Far outside the placed rect, and outside the owner too: the capture,
1772 // not the hit test, is what routes these.
1773 h.pointer(PointerPhase::Move, 200.0, 400.0);
1774 h.pointer(PointerPhase::Up, 210.0, 410.0);
1775 assert_eq!(
1776 h.log(),
1777 vec![
1778 "pod:Down@10,6".to_string(),
1779 "pod:Move@180,156".to_string(),
1780 "pod:Up@190,166".to_string(),
1781 ]
1782 );
1783 assert!(!h.root.is_pointer_captured(), "the Up releases the capture");
1784 // The gesture is over: a further move outside the surface is nobody's.
1785 h.clear_log();
1786 h.pointer(PointerPhase::Move, 200.0, 400.0);
1787 assert!(
1788 !h.log().iter().any(|e| e.starts_with("pod:")),
1789 "a move after the release is no longer the surface's: {:?}",
1790 h.log()
1791 );
1792 }
1793
1794 /// The whole gesture, in the log shape the fixture records it in: pressed
1795 /// inside the placed rect, dragged well outside it, released there.
1796 fn one_gesture(h: &mut Harness, label: &str) {
1797 h.clear_log();
1798 h.down(40.0, 20.0);
1799 assert!(
1800 h.root.is_pointer_captured(),
1801 "{label}: the capture claimed inside the surface opened a gesture"
1802 );
1803 h.pointer(PointerPhase::Move, 300.0, 300.0);
1804 h.pointer(PointerPhase::Up, 300.0, 300.0);
1805 assert_eq!(
1806 h.log(),
1807 vec![
1808 "pod:Down@32,12".to_string(),
1809 "pod:Move@292,292".to_string(),
1810 "pod:Up@292,292".to_string(),
1811 ],
1812 "{label}: every phase of the gesture reached the surface"
1813 );
1814 assert!(
1815 !h.root.is_pointer_captured(),
1816 "{label}: the Up released the capture"
1817 );
1818 }
1819
1820 #[test]
1821 fn a_second_gesture_into_the_same_mounted_pod_routes_exactly_like_the_first() {
1822 let mut h = Harness::new(Cfg {
1823 reaction: Reaction::Capture,
1824 ..Cfg::default()
1825 });
1826 h.frame();
1827 // No frame between the two: the pod is the same widget instance, so the
1828 // second press meets whatever the first gesture left recorded on it.
1829 one_gesture(&mut h, "first gesture");
1830 one_gesture(&mut h, "second gesture");
1831 }
1832
1833 #[test]
1834 fn a_cancelled_gesture_leaves_the_surface_ready_for_the_next_one() {
1835 let mut h = Harness::new(Cfg {
1836 reaction: Reaction::Capture,
1837 ..Cfg::default()
1838 });
1839 h.frame();
1840 h.down(40.0, 20.0);
1841 h.pointer(PointerPhase::Cancel, 300.0, 300.0);
1842 assert!(
1843 !h.root.is_pointer_captured(),
1844 "a Cancel ends the gesture exactly as an Up does"
1845 );
1846 one_gesture(&mut h, "the gesture after a cancel");
1847 }
1848
1849 #[test]
1850 fn a_press_outside_notifies_the_owner_and_can_still_reach_the_main_tree() {
1851 let mut h = Harness::new(Cfg {
1852 outside_tap: Some(OutsideTap::Notify { consume: false }),
1853 ..Cfg::default()
1854 });
1855 h.frame();
1856 h.clear_log();
1857 h.down(200.0, 400.0);
1858 assert_eq!(h.state.outside_taps, 1, "the owner was told");
1859 assert_eq!(
1860 h.log(),
1861 vec![
1862 "sib:Down@200,400".to_string(),
1863 "child:Down@200,400".to_string()
1864 ],
1865 "a pass-through notification still lets the press through"
1866 );
1867 }
1868
1869 #[test]
1870 fn a_consuming_outside_press_is_swallowed() {
1871 let mut h = Harness::new(Cfg {
1872 outside_tap: Some(OutsideTap::Notify { consume: true }),
1873 ..Cfg::default()
1874 });
1875 h.frame();
1876 h.clear_log();
1877 h.down(200.0, 400.0);
1878 assert_eq!(h.state.outside_taps, 1);
1879 assert!(
1880 h.log().is_empty(),
1881 "the dismissing press never reached the main tree: {:?}",
1882 h.log()
1883 );
1884 }
1885
1886 #[test]
1887 fn an_outside_tap_callback_opts_into_the_notification_by_itself() {
1888 // No explicit policy: installing the callback is what asks to hear.
1889 let mut h = Harness::new(Cfg {
1890 outside_tap: None,
1891 ..Cfg::default()
1892 });
1893 h.frame();
1894 h.clear_log();
1895 h.down(200.0, 400.0);
1896 assert_eq!(h.state.outside_taps, 1);
1897 assert!(
1898 h.log().is_empty(),
1899 "the implied policy is the modal one (consuming): {:?}",
1900 h.log()
1901 );
1902 }
1903
1904 #[test]
1905 fn an_ignoring_surface_hears_nothing_about_an_outside_press() {
1906 let mut h = Harness::new(Cfg::default());
1907 h.frame();
1908 h.clear_log();
1909 h.down(200.0, 400.0);
1910 assert_eq!(h.state.outside_taps, 0);
1911 assert_eq!(
1912 h.log(),
1913 vec![
1914 "sib:Down@200,400".to_string(),
1915 "child:Down@200,400".to_string()
1916 ]
1917 );
1918 }
1919
1920 #[test]
1921 fn a_surface_that_claims_focus_takes_it_from_the_child() {
1922 let mut h = Harness::new(Cfg {
1923 reaction: Reaction::Focus,
1924 preserve_focus: false,
1925 ..Cfg::default()
1926 });
1927 h.frame();
1928 // The child is focused first, exactly as a field is before its surface
1929 // opens over it.
1930 h.down(200.0, 400.0);
1931 assert!(h.root.is_focus_active());
1932 h.clear_log();
1933 h.key();
1934 assert_eq!(h.log(), vec!["child:key focus=true".to_string()]);
1935
1936 // A press inside the surface, which claims focus for itself.
1937 h.clear_log();
1938 h.down(40.0, 20.0);
1939 h.key();
1940 assert_eq!(
1941 h.log(),
1942 vec![
1943 "pod:Down@32,12".to_string(),
1944 "pod:key focus=true".to_string()
1945 ],
1946 "the keyboard follows the surface"
1947 );
1948 assert!(h.root.is_focus_active());
1949 }
1950
1951 #[test]
1952 fn preserve_focus_hands_the_session_back_to_the_child() {
1953 let mut h = Harness::new(Cfg {
1954 reaction: Reaction::Focus,
1955 preserve_focus: true,
1956 ..Cfg::default()
1957 });
1958 h.frame();
1959 h.down(200.0, 400.0);
1960 h.clear_log();
1961 h.down(40.0, 20.0);
1962 h.key();
1963 assert_eq!(
1964 h.log(),
1965 vec![
1966 "pod:Down@32,12".to_string(),
1967 "child:key focus=true".to_string()
1968 ],
1969 "the field that opened the surface keeps typing"
1970 );
1971 assert!(h.root.is_focus_active(), "and keeps its session");
1972 }
1973
1974 #[test]
1975 fn a_child_that_takes_focus_back_gets_the_keyboard_again() {
1976 let mut h = Harness::new(Cfg {
1977 reaction: Reaction::Focus,
1978 preserve_focus: false,
1979 ..Cfg::default()
1980 });
1981 h.frame();
1982 // The child is focused, the surface then takes the session…
1983 h.down(200.0, 400.0);
1984 h.down(40.0, 20.0);
1985 // …and the child is pressed again, which re-claims it.
1986 h.down(200.0, 400.0);
1987 h.clear_log();
1988 h.key();
1989 assert_eq!(
1990 h.log(),
1991 vec!["child:key focus=true".to_string()],
1992 "the surface's older link must not outrank the main tree's live one"
1993 );
1994 }
1995
1996 /// A focus link the container never swept must stop deciding where a
1997 /// focus-routed event goes.
1998 ///
1999 /// The field and the portal are siblings here, so the surface's claim
2000 /// arrives as a broadcast the container forwards to both — no hit test, and
2001 /// therefore no blur-on-outside-tap sweep to clear the field's link. Both
2002 /// children then read as focus-link holders, and a container that answers
2003 /// "which child holds focus" with the first one in child order delivers the
2004 /// keyboard to the field the user has left.
2005 #[test]
2006 fn a_stale_sibling_link_does_not_outrank_the_surface_that_took_the_session() {
2007 let mut h = Harness::new(Cfg {
2008 shape: Shape::Sibling,
2009 reaction: Reaction::Focus,
2010 placement: OverlayPlacement::on(OverlaySide::Bottom)
2011 .offset(0.0)
2012 .align(OverlayAlign::Start)
2013 .padding(0.0),
2014 ..Cfg::default()
2015 });
2016 h.frame();
2017
2018 // The field takes the session.
2019 h.down(200.0, 25.0);
2020 h.clear_log();
2021 h.key();
2022 assert_eq!(h.log(), vec!["field:key focus=true".to_string()]);
2023
2024 // A press inside the surface, which claims focus for itself. The
2025 // container sees only the broadcast, so it sweeps nothing.
2026 h.down(40.0, 110.0);
2027 h.clear_log();
2028 h.key();
2029 assert_eq!(
2030 h.log(),
2031 vec!["pod:key focus=true".to_string()],
2032 "the keyboard follows the branch that actually holds the session"
2033 );
2034 }
2035
2036 #[test]
2037 fn a_surface_that_claims_nothing_never_disturbs_the_focused_child() {
2038 let mut h = Harness::new(Cfg::default());
2039 h.frame();
2040 h.down(200.0, 400.0);
2041 h.clear_log();
2042 h.down(40.0, 20.0);
2043 h.key();
2044 assert_eq!(
2045 h.log(),
2046 vec![
2047 "pod:Down@32,12".to_string(),
2048 "child:key focus=true".to_string()
2049 ],
2050 "an overlay press is not a blur"
2051 );
2052 }
2053
2054 #[test]
2055 fn scrolling_an_ancestor_moves_the_surface_on_the_next_paint() {
2056 let mut h = Harness::new(Cfg {
2057 shape: Shape::Scrolled,
2058 placement: OverlayPlacement::default(),
2059 ..Cfg::default()
2060 });
2061 let before = rects(&h.frame());
2062 assert_eq!(
2063 before.last().copied(),
2064 Some(Rect::new(20.0, 244.0, 100.0, 274.0)),
2065 "placed under its anchor, 200px down the scrolled column"
2066 );
2067 h.root.event(
2068 &mut h.state,
2069 &InputEvent::Scroll {
2070 position: Point::new(10.0, 10.0),
2071 delta: ScrollDelta::Pixels(0.0, 50.0),
2072 },
2073 );
2074 let after = rects(&h.frame());
2075 assert_eq!(
2076 after.last().copied(),
2077 Some(Rect::new(20.0, 194.0, 100.0, 224.0)),
2078 "the anchor moved with the scroll, and so did the surface"
2079 );
2080 // …and routing followed it: the pod's own rect is where the press lands.
2081 h.clear_log();
2082 h.down(30.0, 200.0);
2083 assert_eq!(h.log(), vec!["pod:Down@10,6".to_string()]);
2084 }
2085
2086 #[test]
2087 fn clearing_the_overlay_removes_it_after_the_next_paint() {
2088 let mut h = Harness::new(Cfg::default());
2089 assert_eq!(rects(&h.frame()).last().copied(), Some(POD));
2090 h.state.cfg.open = false;
2091 let scene = h.frame();
2092 assert!(
2093 !rects(&scene).contains(&POD),
2094 "nothing floated is painted once the view is gone: {:?}",
2095 rects(&scene)
2096 );
2097 h.clear_log();
2098 h.down(40.0, 20.0);
2099 assert_eq!(
2100 h.log(),
2101 vec!["sib:Down@40,20".to_string(), "child:Down@40,20".to_string()],
2102 "and the press reaches the main tree again"
2103 );
2104 }
2105
2106 // -----------------------------------------------------------------------
2107 // A slot over a `()`-typed pod: the framework-built-surface shape
2108 // -----------------------------------------------------------------------
2109
2110 /// An owner with no children of its own that hosts one `()`-typed surface,
2111 /// builds the floated view itself (as a widget mounting a framework-built
2112 /// surface does) and drains whatever the surface dispatched.
2113 struct ToolbarHost {
2114 log: Log,
2115 reaction: Reaction,
2116 }
2117
2118 struct ToolbarHostWidget {
2119 slot: OverlaySlot<()>,
2120 /// The mounted view, kept so the next rebuild has something to
2121 /// reconcile against.
2122 view: Option<AnyView<()>>,
2123 log: Log,
2124 reaction: Reaction,
2125 }
2126
2127 impl ToolbarHost {
2128 fn pod(&self) -> AnyView<()> {
2129 any(UnitProbe {
2130 log: Rc::clone(&self.log),
2131 reaction: self.reaction,
2132 })
2133 }
2134 }
2135
2136 impl View<()> for ToolbarHost {
2137 type Element = ToolbarHostWidget;
2138 fn build(&self, ctx: &mut BuildCtx<'_>) -> ToolbarHostWidget {
2139 let mut slot = OverlaySlot::new();
2140 slot.set_placement(corner());
2141 let view = self.pod();
2142 slot.rebuild(None, Some(&view), ctx);
2143 ToolbarHostWidget {
2144 slot,
2145 view: Some(view),
2146 log: Rc::clone(&self.log),
2147 reaction: self.reaction,
2148 }
2149 }
2150 fn rebuild(
2151 &self,
2152 _prev: &Self,
2153 element: &mut ToolbarHostWidget,
2154 ctx: &mut BuildCtx<'_>,
2155 ) -> ChangeFlags {
2156 element.log = Rc::clone(&self.log);
2157 element.reaction = self.reaction;
2158 let view = self.pod();
2159 let flags = element
2160 .slot
2161 .rebuild(element.view.as_ref(), Some(&view), ctx);
2162 element.view = Some(view);
2163 flags
2164 }
2165 }
2166
2167 impl Widget for ToolbarHostWidget {
2168 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2169 self.slot.layout(ctx);
2170 bc.constrain(Size::new(100.0, 40.0))
2171 }
2172 fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
2173 let size = ctx.size();
2174 self.slot.paint(ctx, size);
2175 }
2176 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2177 let mut pod_state = ();
2178 let Some(result) = self.slot.event(ctx, event, &mut pod_state) else {
2179 return EventResult::Ignored;
2180 };
2181 // Drained in the same pass the surface dispatched them in — the
2182 // queue is pass-scoped and is not a mailbox.
2183 for command in ctx.take_edit_commands() {
2184 self.log.borrow_mut().push(format!("cmd:{command:?}"));
2185 }
2186 result
2187 }
2188 }
2189
2190 /// The `()`-typed floated content: it carries no application state at all,
2191 /// and speaks to its owner through the edit-command queue.
2192 struct UnitProbe {
2193 log: Log,
2194 reaction: Reaction,
2195 }
2196
2197 struct UnitProbeWidget {
2198 log: Log,
2199 reaction: Reaction,
2200 }
2201
2202 impl View<()> for UnitProbe {
2203 type Element = UnitProbeWidget;
2204 fn build(&self, _ctx: &mut BuildCtx<'_>) -> UnitProbeWidget {
2205 UnitProbeWidget {
2206 log: Rc::clone(&self.log),
2207 reaction: self.reaction,
2208 }
2209 }
2210 fn rebuild(
2211 &self,
2212 _prev: &Self,
2213 element: &mut UnitProbeWidget,
2214 _ctx: &mut BuildCtx<'_>,
2215 ) -> ChangeFlags {
2216 element.log = Rc::clone(&self.log);
2217 element.reaction = self.reaction;
2218 ChangeFlags::NONE
2219 }
2220 }
2221
2222 impl Widget for UnitProbeWidget {
2223 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2224 bc.constrain(Size::new(80.0, 30.0))
2225 }
2226 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
2227 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2228 if let InputEvent::Pointer(p) = event {
2229 self.log.borrow_mut().push(format!(
2230 "pod:{:?}@{},{}",
2231 p.phase, p.position.x, p.position.y
2232 ));
2233 if p.phase == PointerPhase::Down {
2234 match self.reaction {
2235 Reaction::Nothing => {}
2236 Reaction::Capture => ctx.capture_pointer(),
2237 Reaction::Focus => ctx.request_focus(),
2238 }
2239 ctx.dispatch_edit_command(EditCommand::Copy);
2240 ctx.request_redraw();
2241 }
2242 }
2243 EventResult::Handled
2244 }
2245 }
2246
2247 /// Drive the `()`-state host through a real root.
2248 fn unit_harness(reaction: Reaction) -> (RenderRoot<(), ToolbarHost>, Log) {
2249 let log: Log = Rc::new(RefCell::new(Vec::new()));
2250 let mut root: RenderRoot<(), ToolbarHost> = RenderRoot::new();
2251 let captured = Rc::clone(&log);
2252 let mut build = move |_: &mut ()| ToolbarHost {
2253 log: Rc::clone(&captured),
2254 reaction,
2255 };
2256 let mut state = ();
2257 root.rebuild(&mut build, &mut state);
2258 root.layout(WINDOW);
2259 root.paint(&mut RecordingScene::default(), FrameTime::from_nanos(0));
2260 (root, log)
2261 }
2262
2263 #[test]
2264 fn a_unit_typed_pod_is_hosted_and_its_edit_commands_reach_the_owner() {
2265 let (mut root, log) = unit_harness(Reaction::Nothing);
2266 let mut state = ();
2267 root.event(
2268 &mut state,
2269 &InputEvent::Pointer(PointerEvent {
2270 phase: PointerPhase::Down,
2271 position: Point::new(40.0, 50.0),
2272 button: PointerButton::Primary,
2273 }),
2274 );
2275 assert_eq!(
2276 log.borrow().clone(),
2277 vec!["pod:Down@32,10".to_string(), "cmd:Copy".to_string()],
2278 "the surface ran over its own `()` state and its command was drained"
2279 );
2280 }
2281
2282 #[test]
2283 fn a_focus_claim_from_a_substituted_pod_still_opens_a_session() {
2284 let (mut root, _log) = unit_harness(Reaction::Focus);
2285 let mut state = ();
2286 assert!(!root.is_focus_active());
2287 root.event(
2288 &mut state,
2289 &InputEvent::Pointer(PointerEvent {
2290 phase: PointerPhase::Down,
2291 position: Point::new(40.0, 50.0),
2292 button: PointerButton::Primary,
2293 }),
2294 );
2295 assert!(
2296 root.is_focus_active(),
2297 "the substituted context's focus claim is mirrored onto the owner"
2298 );
2299 }
2300
2301 /// One pointer event into the `()`-state host, which carries no application
2302 /// state to thread.
2303 fn unit_pointer(root: &mut RenderRoot<(), ToolbarHost>, phase: PointerPhase, x: f64, y: f64) {
2304 let mut state = ();
2305 root.event(
2306 &mut state,
2307 &InputEvent::Pointer(PointerEvent {
2308 phase,
2309 position: Point::new(x, y),
2310 button: PointerButton::Primary,
2311 }),
2312 );
2313 }
2314
2315 #[test]
2316 fn a_second_gesture_into_a_substituted_pod_routes_exactly_like_the_first() {
2317 let (mut root, log) = unit_harness(Reaction::Capture);
2318 // The substituted route has no second road to the owner: the mirror in
2319 // `forward` is the only thing that can open the root's gesture, so a
2320 // capture it fails to report strands the follow-ups entirely.
2321 for label in ["first gesture", "second gesture"] {
2322 log.borrow_mut().clear();
2323 unit_pointer(&mut root, PointerPhase::Down, 40.0, 50.0);
2324 assert!(
2325 root.is_pointer_captured(),
2326 "{label}: the substituted pod's capture was mirrored onto the owner"
2327 );
2328 unit_pointer(&mut root, PointerPhase::Move, 300.0, 300.0);
2329 unit_pointer(&mut root, PointerPhase::Up, 300.0, 300.0);
2330 assert_eq!(
2331 log.borrow().clone(),
2332 vec![
2333 "pod:Down@32,10".to_string(),
2334 "cmd:Copy".to_string(),
2335 "pod:Move@292,260".to_string(),
2336 "pod:Up@292,260".to_string(),
2337 ],
2338 "{label}: every phase of the gesture reached the surface"
2339 );
2340 assert!(
2341 !root.is_pointer_captured(),
2342 "{label}: the Up released the capture"
2343 );
2344 }
2345 }
2346
2347 #[test]
2348 fn a_slot_starts_closed_and_hands_its_outside_press_over_once() {
2349 let mut slot: OverlaySlot<()> = OverlaySlot::new();
2350 assert!(!slot.is_open());
2351 assert_eq!(slot.window_rect(), Rect::ZERO);
2352 assert!(!slot.take_outside_down());
2353 // Two slots never share an identity, which is the whole addressing rule.
2354 let other: OverlaySlot<()> = OverlaySlot::default();
2355 assert_ne!(slot.key(), other.key());
2356 }
2357}