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 an
33//! `Owner`/`Rect` placement is computed there and nowhere else — the one
34//! exception is [`OverlayAnchor::Window`], which is already stated in this
35//! space by its caller and is instead updated from `event` (see
36//! [`OverlaySlot::set_window_anchor`]), because the pointer position a drag
37//! ghost tracks is itself only ever known there.
38//! * **Owner-local space** — what the owner's own `event` sees: every container
39//! between the root and the owner has already subtracted its origin. An
40//! ordinary pointer event routed to the owner by a live capture arrives here,
41//! so the slot adds the owner origin it recorded at paint time to get back to
42//! window space.
43//! * **Pod space** — the floated pod's own local space, which is window space
44//! minus the placed rect's origin. That single subtraction is the whole
45//! translation into the surface.
46//!
47//! # Capture lifetime
48//!
49//! A pod that captures the pointer records the ordinary
50//! [`frust_core::ChildPod::is_active`] link, and [`OverlaySlot`] clears it on
51//! the `Up`/`Cancel` that ends the gesture which opened it — the same rule the
52//! authoring toolkit's `route_event_single` and the component boundary's own
53//! router apply to every other captured child. The slot dispatches into its pod
54//! directly rather than through either helper (the substituted route below
55//! needs a context it builds itself), so it owns that half of the container
56//! contract too.
57//!
58//! The alternative — leaving the link latched and reading the capture off the
59//! dispatch's own flag instead — was not taken. Both of the slot's capture
60//! mirrors read the *edge* into that link rather than its level, so a link that
61//! is set once and never cleared makes the edge observable once per pod
62//! instance rather than once per gesture, and a surface that stays mounted
63//! loses every gesture after the first. The link's *level* has a reader of its
64//! own besides — a pod holding it is refused a hover claim — which a latch that
65//! never falls would strand just as permanently. Restoring the link's lifetime
66//! answers both; re-deriving one mirror would leave the latch standing for the
67//! other. The slot's own "these owner-local pointer events are the surface's"
68//! flag is then derived from the link rather than latched beside it, so the two
69//! cannot drift apart.
70//!
71//! # Focus lifetime
72//!
73//! A pod's focus link has the same problem the capture link above had, one level
74//! up: a container clears a child's `focused` flag from its own
75//! blur-on-outside-tap sweep, and no container holds this pod, so no sweep ever
76//! reaches it. Nothing here fixes that by hand, and deliberately so — a fix that
77//! lived in this module would cover the surfaces `OverlaySlot` owns and leave
78//! every hand-written owner (and every direct `PaintCtx::register_overlay`
79//! caller) with the original defect.
80//!
81//! `frust-core` gives the link a lifetime instead, where every claim already
82//! passes: a recorded link carries the identity of the focus session it was
83//! recorded for, and the render root moves that identity on whenever a dispatch
84//! records a new claim or ends the session. A link whose stamp names an older
85//! session is retired by arithmetic, with no pass having to visit the branch it
86//! belongs to — and the root retires the flag itself the next time it holds the
87//! pod, during the paint that floats it
88//! ([`ChildPod::retire_stale_focus_link`](frust_core::ChildPod::retire_stale_focus_link)).
89//!
90//! What this module owes that mechanism is to ask the right question:
91//! [`OverlaySlot::pod_has_focus`] reports a link on the **live** session, not a
92//! flag that was once set, and every routing decision here reads it.
93//!
94//! # Tab traversal
95//!
96//! The root never walks a floated pod on its own. [`OverlayPortalWidget`] visits
97//! its pod through [`OverlaySlot::visit_focus_pod`] after its child, so a
98//! non-modal pod's focusable content is reached right after its owner's stops.
99//! A modal pod is the exception: the root walks it alone.
100//!
101//! # Not in v1
102//!
103//! * **The pod contributes no semantics.** A pod's nodes would attach under the
104//! owner's own accessibility node, at the owner's position rather than the
105//! floated rect's, so nothing is published rather than something wrong. An
106//! assistive-technology user reaches a floated surface through the owner
107//! (a field's own actions, a trigger's own node), not through the surface.
108//! * **No hover inside a pod.** The root marks a hover pass on a hit-tested
109//! pointer event only, and an overlay event is a broadcast, so
110//! [`EventCtx::claim_hover`](frust_core::EventCtx::claim_hover) inside a
111//! floated surface records nothing.
112//! * **A substituted pod publishes no IME surface** (see
113//! [`OverlaySlot::event`]); a pod over the ambient application state
114//! ([`OverlaySlot::event_ambient`], the route [`overlay_portal`] takes)
115//! publishes normally.
116//! * **No focus trap and no nesting**, per [`frust_core::overlay`]'s own list.
117
118use std::any::Any;
119use std::cell::RefCell;
120use std::marker::PhantomData;
121use std::rc::Rc;
122
123use frust_core::overlay::OverlayKeys;
124use frust_core::{
125 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
126 Key, LayoutCtx, NamedKey, OutsideTap, OverlayBand, OverlayEntry, OverlayEventKind,
127 OverlayInput, OverlayKey, PaintCtx, PaintScene, PointerEvent, SemanticsCtx, View, Widget, any,
128};
129use kurbo::{Point, Rect, Size, Vec2};
130
131use crate::authoring::{ErasedCallback, erase_callback, releases_capture, route_event_single};
132
133// ---------------------------------------------------------------------------
134// Placement
135// ---------------------------------------------------------------------------
136
137/// The side of the anchor a floated surface opens on.
138///
139/// The web vocabulary every anchored-overlay pattern in this workspace already
140/// speaks (Radix's `side`, and the four-sided tooltip variants each catalog
141/// grew on top of it).
142#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
143pub enum OverlaySide {
144 /// Above the anchor.
145 Top,
146 /// To the trailing side of the anchor.
147 Right,
148 /// Below the anchor — the default.
149 #[default]
150 Bottom,
151 /// To the leading side of the anchor.
152 Left,
153}
154
155impl OverlaySide {
156 /// The side a collision flip lands on.
157 pub const fn opposite(self) -> Self {
158 match self {
159 OverlaySide::Top => OverlaySide::Bottom,
160 OverlaySide::Bottom => OverlaySide::Top,
161 OverlaySide::Left => OverlaySide::Right,
162 OverlaySide::Right => OverlaySide::Left,
163 }
164 }
165
166 /// Whether this side stacks the surface vertically (`Top`/`Bottom`).
167 pub const fn is_vertical(self) -> bool {
168 matches!(self, OverlaySide::Top | OverlaySide::Bottom)
169 }
170}
171
172/// How a floated surface lines up with its anchor on the cross axis.
173#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
174pub enum OverlayAlign {
175 /// Leading edges flush (left edges for a `Top`/`Bottom` side, top edges for
176 /// a `Left`/`Right` one).
177 Start,
178 /// Centres flush — the default.
179 #[default]
180 Center,
181 /// Trailing edges flush.
182 End,
183}
184
185/// A resolved placement request: which side, how it lines up, how far off the
186/// anchor it sits, and how it may move to stay inside the area.
187#[derive(Clone, Copy, Debug, PartialEq)]
188pub struct OverlayPlacement {
189 /// The preferred side.
190 pub side: OverlaySide,
191 /// Cross-axis alignment.
192 pub align: OverlayAlign,
193 /// Gap between the anchor and the surface, in logical px.
194 pub offset: f64,
195 /// Flip to [`OverlaySide::opposite`] when the preferred side does not fit
196 /// and the opposite one does.
197 pub flip: bool,
198 /// Shift the placed rect back inside the padded area when it overflows.
199 pub clamp: bool,
200 /// The margin the surface keeps from every edge of the area, in logical px.
201 /// Both the fit test and the clamp read the area inset by it.
202 pub padding: f64,
203}
204
205/// The default gap between an anchor and the surface placed against it, in
206/// logical px.
207///
208/// The small neutral gap two of the three anchored hosts in this workspace
209/// already default to; a design system that wants a wider one (a panel with a
210/// visible neck) sets [`OverlayPlacement::offset`] in its own wrapper rather
211/// than changing this.
212pub const DEFAULT_OFFSET: f64 = 4.0;
213
214/// The default margin a placed surface keeps from every edge of the area, in
215/// logical px — the viewport padding the collision clamp works against.
216pub const DEFAULT_PADDING: f64 = 8.0;
217
218impl Default for OverlayPlacement {
219 /// Below the anchor, centred, at [`DEFAULT_OFFSET`], flipping and clamping
220 /// inside [`DEFAULT_PADDING`] of the area's edges.
221 fn default() -> Self {
222 OverlayPlacement {
223 side: OverlaySide::default(),
224 align: OverlayAlign::default(),
225 offset: DEFAULT_OFFSET,
226 flip: true,
227 clamp: true,
228 padding: DEFAULT_PADDING,
229 }
230 }
231}
232
233impl OverlayPlacement {
234 /// A placement on `side`, everything else defaulted.
235 pub fn on(side: OverlaySide) -> Self {
236 OverlayPlacement {
237 side,
238 ..Self::default()
239 }
240 }
241
242 /// Set the cross-axis alignment.
243 pub const fn align(mut self, align: OverlayAlign) -> Self {
244 self.align = align;
245 self
246 }
247
248 /// Set the anchor gap, in logical px.
249 pub const fn offset(mut self, offset: f64) -> Self {
250 self.offset = offset;
251 self
252 }
253
254 /// Enable or disable the collision flip.
255 pub const fn flip(mut self, flip: bool) -> Self {
256 self.flip = flip;
257 self
258 }
259
260 /// Enable or disable the shift-back-inside clamp.
261 pub const fn clamp(mut self, clamp: bool) -> Self {
262 self.clamp = clamp;
263 self
264 }
265
266 /// Set the margin kept from the area's edges, in logical px.
267 pub const fn padding(mut self, padding: f64) -> Self {
268 self.padding = padding;
269 self
270 }
271}
272
273/// The region a surface may occupy: `area` inset by `padding` on every side.
274///
275/// Falls back to `area` itself when the inset would invert it — a window
276/// narrower than twice the padding still has to place its surface somewhere,
277/// and an inverted rect would make the clamp below meaningless.
278fn field(area: Rect, padding: f64) -> Rect {
279 let inset = area.inset(-padding);
280 if inset.width() > 0.0 && inset.height() > 0.0 {
281 inset
282 } else {
283 area
284 }
285}
286
287/// Whether a `content`-sized surface fits on `side` of `anchor` inside `field`.
288fn fits(side: OverlaySide, anchor: Rect, content: Size, field: Rect, offset: f64) -> bool {
289 match side {
290 OverlaySide::Top => anchor.y0 - offset - content.height >= field.y0,
291 OverlaySide::Bottom => anchor.y1 + offset + content.height <= field.y1,
292 OverlaySide::Left => anchor.x0 - offset - content.width >= field.x0,
293 OverlaySide::Right => anchor.x1 + offset + content.width <= field.x1,
294 }
295}
296
297/// The cross-axis start coordinate for `align`, given the anchor's own span
298/// `[a0, a1]` and the surface's `extent` along that axis.
299fn align_start(align: OverlayAlign, a0: f64, a1: f64, extent: f64) -> f64 {
300 match align {
301 OverlayAlign::Start => a0,
302 OverlayAlign::Center => (a0 + a1) / 2.0 - extent / 2.0,
303 OverlayAlign::End => a1 - extent,
304 }
305}
306
307/// Shift `rect` back inside `field`, keeping its size.
308///
309/// The start edge wins when the surface is larger than the field (`min` before
310/// `max`): a too-wide panel hangs off the trailing edge rather than the leading
311/// one, where its content starts.
312fn clamp_into(rect: Rect, field: Rect) -> Rect {
313 let x = rect.x0.min(field.x1 - rect.width()).max(field.x0);
314 let y = rect.y0.min(field.y1 - rect.height()).max(field.y0);
315 Rect::from_origin_size(Point::new(x, y), rect.size())
316}
317
318/// Place a `content`-sized surface against `anchor` inside `area`.
319///
320/// All three rects are in one coordinate space — window space, for every caller
321/// inside this module. The returned rect is the surface's placed bounds:
322///
323/// 1. inset `area` by `placement.padding` — every step below works in that
324/// field;
325/// 2. pick the side: the preferred one, or its opposite when `flip` is on, the
326/// preferred one does not fit and the opposite one does;
327/// 3. offset off that edge of the anchor by `offset`, lined up on the cross axis
328/// per `align`;
329/// 4. shift back inside the field when `clamp` is on.
330///
331/// Pure and total: it allocates nothing, reads no context, and is defined for a
332/// degenerate anchor (a zero-size rect places against that point) and for
333/// content larger than the field (which pins to the field's *start* edge).
334///
335/// This is the one copy of geometry each anchored-overlay host in the design
336/// systems had derived for itself (`plugins/shadcn`, `plugins/beui` and
337/// `plugins/material`'s `overlay::anchored`), promoted here so a widget, a
338/// catalog and an app all place a surface the same way.
339pub fn place(anchor: Rect, content: Size, area: Rect, placement: OverlayPlacement) -> Rect {
340 let field = field(area, placement.padding);
341 let mut side = placement.side;
342 if placement.flip
343 && !fits(side, anchor, content, field, placement.offset)
344 && fits(side.opposite(), anchor, content, field, placement.offset)
345 {
346 side = side.opposite();
347 }
348 let origin = match side {
349 OverlaySide::Top => Point::new(
350 align_start(placement.align, anchor.x0, anchor.x1, content.width),
351 anchor.y0 - placement.offset - content.height,
352 ),
353 OverlaySide::Bottom => Point::new(
354 align_start(placement.align, anchor.x0, anchor.x1, content.width),
355 anchor.y1 + placement.offset,
356 ),
357 OverlaySide::Left => Point::new(
358 anchor.x0 - placement.offset - content.width,
359 align_start(placement.align, anchor.y0, anchor.y1, content.height),
360 ),
361 OverlaySide::Right => Point::new(
362 anchor.x1 + placement.offset,
363 align_start(placement.align, anchor.y0, anchor.y1, content.height),
364 ),
365 };
366 let rect = Rect::from_origin_size(origin, content);
367 if placement.clamp {
368 clamp_into(rect, field)
369 } else {
370 rect
371 }
372}
373
374// ---------------------------------------------------------------------------
375// The owner's slot
376// ---------------------------------------------------------------------------
377
378/// What a slot places its surface against.
379///
380/// `Owner` and `Rect` resolve to a **window-space** rect in the owner's
381/// `paint`, where the owner's absolute origin is finally known; nothing is
382/// subscribed to and nothing is cached across frames, so an anchor follows
383/// its owner across scroll, relayout and animation for free. `Window` is
384/// already window-space and skips that resolution entirely — see its own
385/// docs for why it is updated from `event` instead.
386#[derive(Clone, Copy, Debug, Default, PartialEq)]
387pub enum OverlayAnchor {
388 /// The owner's own bounds — a trigger floating a menu under itself.
389 #[default]
390 Owner,
391 /// A rect in the **owner's local space**: a caret, a selection's bounding
392 /// box, a press point, one row of a list. Translated by the owner's paint
393 /// origin, so a caller states it in the same coordinates its `layout` and
394 /// `event` already speak.
395 Rect(Rect),
396 /// An absolute **window-space** point the surface's top-left tracks
397 /// directly — a drag ghost following the pointer rather than an owner's
398 /// bounds.
399 ///
400 /// The placed rect is always `point + offset`, sized to the pod's own
401 /// content size: no side, alignment or collision flip applies, because
402 /// there is no anchor rect to place against, only a point — so none of
403 /// [`OverlayPlacement`]'s side/align/flip/clamp fields apply to it either
404 /// (`place` is never called for this variant). The rect is clamped into
405 /// the window area when [`OverlaySlot::set_clamp_to_window`] has turned
406 /// that on (default `false`, so the ghost may trail off the window's edge
407 /// with the pointer).
408 ///
409 /// Set once (with whatever `offset` the gesture's grab point needs) via
410 /// [`OverlaySlot::set_anchor`], then updated every pointer `Move`/`Down`
411 /// with [`OverlaySlot::set_window_anchor`], which keeps `offset` and only
412 /// moves `point` — see that method for why the update belongs in the
413 /// owner's `event` rather than waiting for the next `paint`.
414 Window {
415 /// The absolute window-space point the rect's top-left tracks.
416 point: Point,
417 /// A fixed offset from `point` to the rect's actual top-left — the
418 /// grab point within a dragged ghost, say.
419 offset: Vec2,
420 },
421}
422
423/// One floated surface an owner hosts: the pod, where it goes, and the routing
424/// of the input the root sends back to it.
425///
426/// # The four calls
427///
428/// An owner widget forwards four of its own lifecycle calls here, and the slot
429/// does nothing on its own:
430///
431/// 1. [`rebuild`](Self::rebuild) from the owner's `View::rebuild`, with the
432/// overlay view it wants mounted (or `None` to drop it).
433/// 2. [`layout`](Self::layout) from the owner's `Widget::layout` — the pod is
434/// laid out loosely against the **window**, never the owner's own
435/// constraints, because it escapes the owner's box entirely.
436/// 3. [`paint`](Self::paint) from the owner's `Widget::paint`, which computes
437/// the placement and registers the pod. It paints nothing: the root paints
438/// every registered pod after the main tree, which is the only way a surface
439/// escapes its owner's paint order and every ancestor's clip.
440/// 4. [`event`](Self::event) (or [`event_ambient`](Self::event_ambient)) from
441/// the owner's `Widget::event`, **before** the owner routes to its own
442/// children. `None` means "not mine" and the owner carries on.
443///
444/// # `PodState`
445///
446/// The state type the floated view is diffed against. Two shapes exist and the
447/// dispatch call differs between them:
448///
449/// * the pod is built over the **ambient application state** (what
450/// [`overlay_portal`] does): use [`event_ambient`](Self::event_ambient), which
451/// forwards through the owner's own [`EventCtx`] so focus, capture, hover and
452/// IME all bubble exactly as they do for any other child;
453/// * the pod is built over a **different** state — `()` for a
454/// framework-built surface whose callbacks carry their own handles: use
455/// [`event`](Self::event) and hand it `&mut PodState`, which dispatches over a
456/// substituted context (see that method for what does and does not bubble
457/// through the substitution).
458pub struct OverlaySlot<PodState: 'static> {
459 /// This surface's identity, allocated once and quoted back by every routed
460 /// event. Stable for the slot's life — re-allocating per frame would hand
461 /// the root a new identity every paint.
462 key: OverlayKey,
463 /// The mounted pod, shared with the registration the root paints. `None`
464 /// while the surface is closed.
465 pod: Option<Rc<RefCell<ChildPod>>>,
466 band: OverlayBand,
467 input: OverlayInput,
468 outside_tap: OutsideTap,
469 keys: OverlayKeys,
470 placement: OverlayPlacement,
471 anchor: OverlayAnchor,
472 /// Whether an [`OverlayAnchor::Window`] rect is clamped back inside the
473 /// window area. No effect on any other anchor, which is placed (and, per
474 /// its own `clamp`, possibly shifted back) by [`place`] instead. See
475 /// [`set_clamp_to_window`](Self::set_clamp_to_window).
476 clamp_to_window: bool,
477 /// The window size the last layout pass saw — the area the placement is
478 /// computed against, recorded in `layout` because a paint context carries
479 /// no window size of its own.
480 window: Size,
481 /// Where the last paint placed the surface, in window space: what was
482 /// registered, what the root hit-tests, and what a routed position is made
483 /// pod-local against.
484 window_rect: Rect,
485 /// The owner's own absolute origin as of the last paint — what an
486 /// owner-local pointer position (a captured drag) is lifted into window
487 /// space with.
488 owner_origin: Point,
489 /// Whether the pod holds the pointer capture, so ordinary pointer events
490 /// routed to the owner by the capture path belong to the surface.
491 ///
492 /// Recomputed from the pod's own recorded link by every dispatch through
493 /// `forward` — the one writer while a pod is mounted — rather than latched
494 /// alongside it. Mounting, dropping or replacing the pod resets it for the
495 /// same reason: the link it mirrors goes with the widget that held it.
496 captured: bool,
497 /// A press landed outside every floated surface and this one asked to hear
498 /// about it; drained by [`take_outside_down`](Self::take_outside_down).
499 outside_down_pending: bool,
500 /// A declined Escape reached the surface and the pod did not consume it; drained by
501 /// [`take_escape`](Self::take_escape).
502 escape_pending: bool,
503 _state: PhantomData<fn(&mut PodState)>,
504}
505
506impl<PodState: 'static> Default for OverlaySlot<PodState> {
507 fn default() -> Self {
508 Self::new()
509 }
510}
511
512impl<PodState: 'static> OverlaySlot<PodState> {
513 /// A closed slot with a fresh identity: `Floating`, interactive, ignoring
514 /// outside taps, anchored to its owner's own bounds.
515 ///
516 /// Built **once**, when the owner widget is built, and kept: the key is the
517 /// whole addressing mechanism between the root and this surface.
518 pub fn new() -> Self {
519 Self {
520 key: OverlayKey::next(),
521 pod: None,
522 band: OverlayBand::Floating,
523 input: OverlayInput::Interactive,
524 outside_tap: OutsideTap::Ignore,
525 keys: OverlayKeys::Pass,
526 placement: OverlayPlacement::default(),
527 anchor: OverlayAnchor::Owner,
528 clamp_to_window: false,
529 window: Size::ZERO,
530 window_rect: Rect::ZERO,
531 owner_origin: Point::ZERO,
532 captured: false,
533 outside_down_pending: false,
534 escape_pending: false,
535 _state: PhantomData,
536 }
537 }
538
539 /// This surface's identity.
540 pub fn key(&self) -> OverlayKey {
541 self.key
542 }
543
544 /// Whether a pod is currently mounted.
545 pub fn is_open(&self) -> bool {
546 self.pod.is_some()
547 }
548
549 /// Where the last paint placed the surface, in window space.
550 ///
551 /// [`Rect::ZERO`] before the first paint of an open slot — a surface that
552 /// has never been painted has never been registered, so nothing routes to
553 /// it either.
554 pub fn window_rect(&self) -> Rect {
555 self.window_rect
556 }
557
558 /// Whether the pod holds a focus link on the **live** session — the read
559 /// every routing decision here makes.
560 ///
561 /// Not the raw recorded flag: a claim made from inside a floated surface
562 /// reaches no container's blur sweep, so a link this surface recorded
563 /// survives the session moving away from it (see the module docs' *Focus
564 /// lifetime*). Asking the raw flag would keep routing the keyboard into a
565 /// surface the user has left.
566 pub fn pod_has_focus(&self) -> bool {
567 self.pod
568 .as_ref()
569 .is_some_and(|pod| pod.borrow().holds_live_focus())
570 }
571
572 /// Visit the mounted pod for the focus walk, if one is mounted and not
573 /// currently borrowed. The owner calls this from its
574 /// `Widget::visit_focus_children`, after its own children, so a floated
575 /// pod's stops follow its owner's. The root's walk decides what the pod
576 /// contributes; a closed slot visits nothing.
577 pub fn visit_focus_pod(&self, visitor: &mut dyn FnMut(&ChildPod)) {
578 if let Some(pod) = &self.pod
579 && let Ok(pod) = pod.try_borrow()
580 {
581 visitor(&pod);
582 }
583 }
584
585 /// Drop the pod's recorded focus link, so focus-routed events stop reaching
586 /// it — the owner's half of "the surface asked for focus, and the owner
587 /// declined on its behalf".
588 ///
589 /// An owner's *decision*, and the only reason this exists: a link the
590 /// session has merely moved away from needs no call, since its stamp retires
591 /// it on its own (see the module docs' *Focus lifetime*).
592 pub fn withdraw_pod_focus(&mut self) {
593 if let Some(pod) = &self.pod {
594 pod.borrow_mut().set_focused(false);
595 }
596 }
597
598 /// Take the pending outside-press notification, clearing it.
599 ///
600 /// `true` exactly once per press that landed outside every floated surface
601 /// while this one was registered [`OutsideTap::Notify`] — the light-dismiss
602 /// signal an owner closes on.
603 pub fn take_outside_down(&mut self) -> bool {
604 std::mem::take(&mut self.outside_down_pending)
605 }
606
607 /// Take the pending Escape notification, clearing it.
608 ///
609 /// `true` once per declined Escape the root delivered here as
610 /// [`OverlayEventKind::Key`] (a [modal](OverlayKeys::Modal) surface, or the
611 /// forward target of a focused editable) that the pod itself did not consume
612 /// — the close signal. An owner that acts on it calls
613 /// [`EventCtx::consume_overlay_key`] so the key stops there.
614 pub fn take_escape(&mut self) -> bool {
615 std::mem::take(&mut self.escape_pending)
616 }
617
618 /// Which z-band the surface paints and hit-tests in.
619 pub fn set_band(&mut self, band: OverlayBand) {
620 self.band = band;
621 }
622
623 /// Whether the surface takes pointer input at all.
624 pub fn set_input(&mut self, input: OverlayInput) {
625 self.input = input;
626 }
627
628 /// What a press outside every floated surface delivers here.
629 pub fn set_outside_tap(&mut self, outside_tap: OutsideTap) {
630 self.outside_tap = outside_tap;
631 }
632
633 /// Whether the surface claims declined keys: [`OverlayKeys::Modal`] masks
634 /// main-tree shortcuts and takes a declined Escape (see [`frust_core::overlay`]).
635 pub fn set_keys(&mut self, keys: OverlayKeys) {
636 self.keys = keys;
637 }
638
639 /// Where the surface sits relative to its anchor.
640 pub fn set_placement(&mut self, placement: OverlayPlacement) {
641 self.placement = placement;
642 }
643
644 /// What the surface is placed against.
645 pub fn set_anchor(&mut self, anchor: OverlayAnchor) {
646 self.anchor = anchor;
647 }
648
649 /// Move an [`OverlayAnchor::Window`] anchor's point, keeping whatever
650 /// `offset` it already carries (or [`Vec2::ZERO`] if the slot was not
651 /// already `Window`-anchored) — the owner's call from its own `event`
652 /// handler on every pointer `Move`/`Down`, so a pointer-following pod (a
653 /// drag ghost) tracks the pointer without waiting for its own paint to
654 /// recompute a bounds-derived anchor the way [`Owner`](OverlayAnchor::Owner)
655 /// and [`Rect`](OverlayAnchor::Rect) do: both of those are only ever
656 /// re-derived from the owner's own `paint` (`ctx.origin()`, read there and
657 /// nowhere else), which is fine when the anchor only ever moves because
658 /// the owner itself moved or repainted for some unrelated reason, but a
659 /// ghost has to repaint at frame cadence as the pointer moves, which a
660 /// call from `event` is the only door onto.
661 ///
662 /// So, unlike [`set_anchor`](Self::set_anchor) (a plain field write the
663 /// next `paint` happens to pick up), this also requests a **repaint
664 /// only** ([`EventCtx::request_redraw`]) — never a relayout — of the
665 /// owner: `self.window` (the area) and the pod's own size are unaffected
666 /// by where the pointer now is, so the usual `ChangeFlags::LAYOUT` round
667 /// trip through `rebuild` would be wasted work the pointer cannot wait
668 /// for anyway.
669 pub fn set_window_anchor(&mut self, ctx: &mut EventCtx<'_>, point: Point) {
670 let offset = match self.anchor {
671 OverlayAnchor::Window { offset, .. } => offset,
672 _ => Vec2::ZERO,
673 };
674 self.anchor = OverlayAnchor::Window { point, offset };
675 ctx.request_redraw();
676 }
677
678 /// Whether an [`OverlayAnchor::Window`] rect is clamped back inside the
679 /// window area (default `false` — a drag ghost is allowed to trail off
680 /// the window's edge with the pointer). No effect on an `Owner`/`Rect`
681 /// anchor, whose own [`OverlayPlacement::clamp`] already governs it.
682 pub fn set_clamp_to_window(&mut self, clamp_to_window: bool) {
683 self.clamp_to_window = clamp_to_window;
684 }
685
686 /// Mount, reconcile or drop the floated view — the owner's `View::rebuild`
687 /// half.
688 ///
689 /// `prev`/`next` are the previous and current frame's overlay views, in the
690 /// shape [`rebuild_child`](crate::authoring::rebuild_child) itself takes: a
691 /// `None` → `Some` transition builds the pod, `Some` → `Some` reconciles it
692 /// in place (so a kept-open surface keeps its own widget state), `Some` →
693 /// `None` tears it down, and `None` → `None` does nothing. An owner that
694 /// mounts a view it builds itself (from a process-global builder, say)
695 /// keeps the previous one and hands both in.
696 ///
697 /// Dropping the pod also drops any capture it held: the widget that was
698 /// mid-gesture no longer exists, so there is nothing to unwind and nothing
699 /// to route follow-ups to.
700 pub fn rebuild(
701 &mut self,
702 prev: Option<&AnyView<PodState>>,
703 next: Option<&AnyView<PodState>>,
704 ctx: &mut BuildCtx<'_>,
705 ) -> ChangeFlags {
706 match (prev, next) {
707 (Some(prev), Some(next)) => match &self.pod {
708 Some(pod) => {
709 let mut pod = pod.borrow_mut();
710 crate::authoring::rebuild_child(prev, next, &mut pod, ctx)
711 }
712 // The view stayed mounted but the pod did not: build a fresh
713 // one rather than route into nothing.
714 None => self.mount(next, ctx),
715 },
716 (None, Some(next)) => {
717 // Nothing to reconcile against — an existing pod here belongs to
718 // a view the owner no longer has, so it is replaced rather than
719 // diffed (a torn-down widget's state dies with it either way).
720 self.drop_pod();
721 self.mount(next, ctx)
722 }
723 (Some(prev), None) => match self.pod.take() {
724 Some(pod) => {
725 {
726 let mut pod = pod.borrow_mut();
727 crate::authoring::teardown_child(prev, &mut pod, ctx);
728 }
729 self.captured = false;
730 ChangeFlags::LAYOUT
731 }
732 None => ChangeFlags::NONE,
733 },
734 (None, None) => {
735 if self.pod.is_some() {
736 self.drop_pod();
737 ChangeFlags::LAYOUT
738 } else {
739 ChangeFlags::NONE
740 }
741 }
742 }
743 }
744
745 /// Build `view` into a fresh pod, replacing whatever was mounted.
746 fn mount(&mut self, view: &AnyView<PodState>, ctx: &mut BuildCtx<'_>) -> ChangeFlags {
747 let pod = crate::authoring::build_child(view, ctx);
748 self.pod = Some(Rc::new(RefCell::new(pod)));
749 self.captured = false;
750 ChangeFlags::LAYOUT
751 }
752
753 /// Drop the pod with no view to tear it down through — the recovery arm for
754 /// a slot whose mounted view vanished without one.
755 fn drop_pod(&mut self) {
756 self.pod = None;
757 self.captured = false;
758 }
759
760 /// Lay the pod out against the window — the owner's `Widget::layout` half.
761 ///
762 /// Loose constraints against
763 /// [`LayoutCtx::window_size`](frust_core::LayoutCtx::window_size), never the
764 /// owner's own `bc`: the surface escapes the owner's box, so the owner's
765 /// constraints say nothing about how much room it has. The pod's own origin
766 /// stays [`Point::ZERO`] — the root paints it at the registered rect's
767 /// origin and adds the pod's origin on top, and routing tests the registered
768 /// rect alone, so any other value would desynchronize paint from hit test.
769 pub fn layout(&mut self, ctx: &mut LayoutCtx) {
770 self.window = ctx.window_size();
771 if let Some(pod) = &self.pod {
772 let bc = BoxConstraints::loose(self.window);
773 let mut pod = pod.borrow_mut();
774 pod.layout_child(ctx, &bc);
775 pod.set_origin(Point::ZERO);
776 }
777 }
778
779 /// Place and register the pod — the owner's `Widget::paint` half.
780 ///
781 /// Computes the anchor rect in window space from
782 /// [`PaintCtx::origin`](frust_core::PaintCtx::origin) (plus the local rect,
783 /// for [`OverlayAnchor::Rect`]), places the pod against it with [`place`],
784 /// records the result and hands the root a registration. **It paints
785 /// nothing**: an owner that also painted the pod would draw the surface
786 /// twice, once clipped in place and once floated.
787 ///
788 /// Registration is per paint pass, so a surface stays alive exactly while
789 /// its owner keeps painting — an owner that is culled, unmounted or simply
790 /// stops registering disappears from the routing table after the next paint
791 /// with nothing to unregister.
792 pub fn paint(&mut self, ctx: &mut PaintCtx, owner_size: Size) {
793 let Some(pod) = &self.pod else {
794 return;
795 };
796 self.owner_origin = ctx.origin();
797 let area = Rect::from_origin_size(Point::ZERO, self.window);
798 let content = pod.borrow().size();
799 self.window_rect = match self.anchor {
800 OverlayAnchor::Owner => {
801 let anchor = Rect::from_origin_size(ctx.origin(), owner_size);
802 place(anchor, content, area, self.placement)
803 }
804 OverlayAnchor::Rect(local) => {
805 let anchor = local + ctx.origin().to_vec2();
806 place(anchor, content, area, self.placement)
807 }
808 // No side, alignment or collision flip applies to a single point:
809 // the rect is stated directly and only `self.clamp_to_window` — a
810 // separate knob from `place`'s own `self.placement.clamp` — may
811 // pull it back inside the (unpadded) window area.
812 OverlayAnchor::Window { point, offset } => {
813 let rect = Rect::from_origin_size(point + offset, content);
814 if self.clamp_to_window {
815 clamp_into(rect, area)
816 } else {
817 rect
818 }
819 }
820 };
821 ctx.register_overlay(OverlayEntry {
822 key: self.key,
823 band: self.band,
824 input: self.input,
825 outside_tap: self.outside_tap,
826 keys: self.keys,
827 window_rect: self.window_rect,
828 pod: Rc::clone(pod),
829 insets: ctx.window_insets(),
830 });
831 }
832
833 /// Route an event into the surface over a **substituted** state — the
834 /// owner's `Widget::event` half for a pod whose `PodState` is not the
835 /// ambient application state (a `()`-typed, framework-built surface).
836 ///
837 /// `Some(_)` means the slot owned the event and the owner must not route it
838 /// on; `None` means it belongs to the owner's ordinary routing.
839 ///
840 /// # What crosses the substitution
841 ///
842 /// The pod runs over a fresh [`EventCtx`] built on `state`, exactly as a
843 /// component boundary runs its subtree over its own local state, and the
844 /// results are mirrored back onto the owner's context: a redraw request, a
845 /// pointer capture, and a focus claim or release (observed through the pod's
846 /// own recorded link). A published IME surface and a hover claim do **not**
847 /// cross — an IME publish has no route back through a substituted context,
848 /// and an overlay event is a broadcast, which records no hover anywhere. A
849 /// surface that needs either is built over the ambient state instead (see
850 /// [`event_ambient`](Self::event_ambient)).
851 ///
852 /// Edit commands do cross, by a different road: they ride a pass-scoped
853 /// queue rather than the context, so a pod that calls
854 /// [`EventCtx::dispatch_edit_command`](frust_core::EventCtx::dispatch_edit_command)
855 /// is drained by the owner's
856 /// [`EventCtx::take_edit_commands`](frust_core::EventCtx::take_edit_commands)
857 /// in the same pass.
858 pub fn event(
859 &mut self,
860 ctx: &mut EventCtx<'_>,
861 event: &InputEvent,
862 state: &mut PodState,
863 ) -> Option<EventResult> {
864 let substitute: &mut dyn Any = state;
865 self.route(ctx, event, Some(substitute))
866 }
867
868 /// Route an event into the surface over the **ambient** application state —
869 /// the owner's `Widget::event` half for a pod built over the same state the
870 /// owner itself is diffed against.
871 ///
872 /// The pod is dispatched through the owner's own [`EventCtx`], so
873 /// everything a child normally bubbles (redraw, capture, focus, a published
874 /// IME surface) reaches the root unchanged, and the pod's callbacks reach
875 /// the same application state every other widget sees. This is the route
876 /// [`overlay_portal`] takes.
877 pub fn event_ambient(
878 &mut self,
879 ctx: &mut EventCtx<'_>,
880 event: &InputEvent,
881 ) -> Option<EventResult> {
882 self.route(ctx, event, None)
883 }
884
885 /// The shared body of [`event`](Self::event)/
886 /// [`event_ambient`](Self::event_ambient): decide whether this event belongs
887 /// to the surface and, if it does, translate it into pod space and forward
888 /// it.
889 fn route(
890 &mut self,
891 ctx: &mut EventCtx<'_>,
892 event: &InputEvent,
893 substitute: Option<&mut dyn Any>,
894 ) -> Option<EventResult> {
895 // A closed slot owns nothing: every event belongs to the owner.
896 self.pod.as_ref()?;
897 let origin = self.window_rect.origin().to_vec2();
898 match event {
899 // A floated surface's own input, broadcast to the whole tree so it
900 // reaches this owner wherever it sits. The key comparison is the
901 // entire addressing mechanism: another owner's surface falls
902 // through untouched.
903 InputEvent::Overlay(overlay) if overlay.key == self.key => {
904 match &overlay.kind {
905 OverlayEventKind::Pointer(pointer) => {
906 let local = InputEvent::Pointer(PointerEvent {
907 position: pointer.position - origin,
908 ..*pointer
909 });
910 self.forward(ctx, &local, substitute);
911 }
912 OverlayEventKind::Scroll { position, delta } => {
913 let local = InputEvent::Scroll {
914 position: *position - origin,
915 delta: *delta,
916 };
917 self.forward(ctx, &local, substitute);
918 }
919 // A scale gesture is routed on the same terms as a scroll:
920 // only its focal point needs lifting into surface space.
921 OverlayEventKind::Scale {
922 focal,
923 phase,
924 scale_delta,
925 velocity,
926 } => {
927 let local = InputEvent::Scale(frust_core::event::ScaleEvent {
928 phase: *phase,
929 scale_delta: *scale_delta,
930 focal: *focal - origin,
931 velocity: *velocity,
932 });
933 self.forward(ctx, &local, substitute);
934 }
935 // The press landed on nothing floated: the surface never saw
936 // it, so nothing is forwarded — the owner reads the
937 // notification and decides whether to close.
938 OverlayEventKind::OutsideDown => self.outside_down_pending = true,
939 // A shortcut a widget inside the pod declared: delivered there as the
940 // plain broadcast its declarer recognises.
941 OverlayEventKind::Shortcut(id) => {
942 self.forward(ctx, &InputEvent::Shortcut(*id), substitute);
943 }
944 // A key the focus path declined: offered to the pod first; an
945 // unconsumed Escape is left for the owner to close on.
946 OverlayEventKind::Key(key) => {
947 let local = InputEvent::Key(key.clone());
948 if self.forward(ctx, &local, substitute) == EventResult::Handled {
949 ctx.consume_overlay_key();
950 } else if key.key == Key::Named(NamedKey::Escape)
951 && key.modifiers == frust_core::Modifiers::default()
952 {
953 self.escape_pending = true;
954 }
955 }
956 }
957 // A broadcast is never consumed, whatever the pod returned.
958 Some(EventResult::Ignored)
959 }
960 // A gesture that began inside the surface: the capture it opened
961 // short-circuits the root's overlay pre-pass, so its follow-ups
962 // arrive here as ordinary pointer events in the OWNER's local space
963 // and have to be lifted back into window space first.
964 InputEvent::Pointer(pointer) if self.captured => {
965 let local = InputEvent::Pointer(PointerEvent {
966 position: pointer.position + self.owner_origin.to_vec2() - origin,
967 ..*pointer
968 });
969 // The release that ends the gesture is handled where the pod
970 // is dispatched, so it lands whichever door the event came in
971 // by — this one, or the broadcast arm above.
972 Some(self.forward(ctx, &local, substitute))
973 }
974 // Keyboard, IME and the clipboard verbs: focus-routed, so they only
975 // arrive here at all because the owner is on the recorded focus
976 // chain — and they belong to the surface exactly when the surface is
977 // what claimed focus.
978 event if event.is_focus_routed() && self.pod_has_focus() => {
979 Some(self.forward(ctx, event, substitute))
980 }
981 _ => None,
982 }
983 }
984
985 /// Dispatch `local` (already in pod space) into the pod, updating the
986 /// slot's own capture bookkeeping and, for a substituted state, mirroring
987 /// the pod's results onto the owner's context.
988 fn forward(
989 &mut self,
990 ctx: &mut EventCtx<'_>,
991 local: &InputEvent,
992 substitute: Option<&mut dyn Any>,
993 ) -> EventResult {
994 let Some(pod) = self.pod.clone() else {
995 return EventResult::Ignored;
996 };
997 let mut pod = pod.borrow_mut();
998 let was_active = pod.is_active();
999 let was_focused = pod.is_focused();
1000 let result = match substitute {
1001 // The ambient route: the pod is a child like any other, and
1002 // `event_child` bubbles its redraw/capture/focus/IME flags into the
1003 // owner's context on its own.
1004 None => pod.event_child(ctx, local),
1005 Some(state) => {
1006 let (result, needs_redraw, needs_layout) = {
1007 let mut inner =
1008 EventCtx::new(state, self.window_rect.origin(), self.window_rect.size());
1009 let result = pod.event_child(&mut inner, local);
1010 (result, inner.needs_redraw(), inner.needs_layout())
1011 };
1012 if needs_redraw {
1013 ctx.request_redraw();
1014 }
1015 // A layout request crosses the substituted context like the
1016 // redraw does, so the root's pending flags carry LAYOUT and a
1017 // shell gating layout on them runs the pass. (The floated pod
1018 // itself was marked dirty by `event_child`; the root reaches it
1019 // through its dirty-surface check.)
1020 if needs_layout {
1021 ctx.request_layout();
1022 }
1023 // Capture and focus are mirrored through the pod's own recorded
1024 // links rather than the substituted context's flags: the pod
1025 // records exactly what its widget asked for, and reading it here
1026 // keeps one mirror instead of two.
1027 if pod.is_active() && !was_active {
1028 ctx.capture_pointer();
1029 }
1030 match (was_focused, pod.is_focused()) {
1031 (false, true) => ctx.request_focus(),
1032 (true, false) => ctx.release_focus(),
1033 _ => {}
1034 }
1035 result
1036 }
1037 };
1038 // Give the link the lifetime every other container gives a captured
1039 // child. `event_child` only ever sets it, and this is the one dispatch
1040 // path that does not run through a routing helper, so the release has
1041 // to happen here or never — and until it does, the two `was_active`
1042 // edges above are edges into a latch that never falls.
1043 if was_active && releases_capture(local) {
1044 pod.set_active(false);
1045 }
1046 self.captured = pod.is_active();
1047 result
1048 }
1049}
1050
1051// ---------------------------------------------------------------------------
1052// The declarative portal
1053// ---------------------------------------------------------------------------
1054
1055/// Float `overlay` above the whole app, anchored to `child`'s bounds — the
1056/// declarative half of the portal.
1057///
1058/// The child is laid out, painted and routed exactly as it would be without the
1059/// wrapper: the portal adds nothing to its geometry and consumes none of its
1060/// input. The overlay is mounted while [`OverlayPortalView::overlay`] is
1061/// `Some`, which is also how an exit animation is expressed — keep handing the
1062/// same view in while the surface ramps out, and hand `None` once it has.
1063///
1064/// ```
1065/// use frust_widgets::{OverlayPlacement, OverlaySide, overlay_portal, text};
1066///
1067/// struct App {
1068/// hovering: bool,
1069/// }
1070///
1071/// fn tip(state: &mut App) -> impl frust_core::View<App> + use<> {
1072/// overlay_portal(text("save"))
1073/// .overlay(state.hovering.then(|| text("Save the document")))
1074/// .placement(OverlayPlacement::on(OverlaySide::Top))
1075/// }
1076/// # let _ = tip;
1077/// ```
1078pub fn overlay_portal<State: 'static, V: View<State>>(child: V) -> OverlayPortalView<State> {
1079 OverlayPortalView {
1080 child: any(child),
1081 overlay: None,
1082 placement: OverlayPlacement::default(),
1083 band: OverlayBand::Floating,
1084 input: OverlayInput::Interactive,
1085 outside_tap: OutsideTap::Ignore,
1086 keys: OverlayKeys::Pass,
1087 on_outside_tap: None,
1088 on_escape: None,
1089 preserve_focus: false,
1090 }
1091}
1092
1093/// The light-dismiss callback a portal holds before it is erased.
1094type OnOutsideTap<State> = Rc<dyn Fn(&mut State)>;
1095
1096/// A declarative overlay portal. See [`overlay_portal`].
1097pub struct OverlayPortalView<State: 'static> {
1098 child: AnyView<State>,
1099 overlay: Option<AnyView<State>>,
1100 placement: OverlayPlacement,
1101 band: OverlayBand,
1102 input: OverlayInput,
1103 outside_tap: OutsideTap,
1104 keys: OverlayKeys,
1105 on_outside_tap: Option<OnOutsideTap<State>>,
1106 on_escape: Option<OnOutsideTap<State>>,
1107 preserve_focus: bool,
1108}
1109
1110impl<State: 'static> OverlayPortalView<State> {
1111 /// The floated surface: `Some` mounts it, `None` drops it after the next
1112 /// paint.
1113 ///
1114 /// The view is diffed against the **same** application state the portal
1115 /// itself is, so the surface reads and writes app state exactly like the
1116 /// child does — it is a logical child of this call site that happens to be
1117 /// painted elsewhere.
1118 ///
1119 /// A bare `None` needs the surface's view type spelled out, e.g.
1120 /// `.overlay(None::<AnyView<State>>)`.
1121 pub fn overlay<V: View<State>>(mut self, overlay: Option<V>) -> Self {
1122 self.overlay = overlay.map(any);
1123 self
1124 }
1125
1126 /// Where the surface sits relative to the child's bounds (default: below,
1127 /// centred — see [`OverlayPlacement::default`]).
1128 pub fn placement(mut self, placement: OverlayPlacement) -> Self {
1129 self.placement = placement;
1130 self
1131 }
1132
1133 /// Which z-band the surface paints and hit-tests in (default
1134 /// [`OverlayBand::Floating`]; a tooltip belongs in
1135 /// [`OverlayBand::Tooltip`]).
1136 pub fn band(mut self, band: OverlayBand) -> Self {
1137 self.band = band;
1138 self
1139 }
1140
1141 /// Whether the surface takes pointer input (default
1142 /// [`OverlayInput::Interactive`]; explanatory chrome the pointer passes
1143 /// through is [`OverlayInput::Transparent`]).
1144 pub fn input(mut self, input: OverlayInput) -> Self {
1145 self.input = input;
1146 self
1147 }
1148
1149 /// What a press landing outside every floated surface delivers here
1150 /// (default [`OutsideTap::Ignore`]).
1151 pub fn outside_tap(mut self, outside_tap: OutsideTap) -> Self {
1152 self.outside_tap = outside_tap;
1153 self
1154 }
1155
1156 /// Run `callback` when a press lands outside every floated surface — the
1157 /// light-dismiss hook.
1158 ///
1159 /// Setting it also opts the surface into the notification
1160 /// ([`OutsideTap::Notify`] with `consume: true`, the modal shape) unless an
1161 /// explicit [`outside_tap`](Self::outside_tap) says otherwise, so the tap
1162 /// that dismisses a menu does not also activate what sits under it. Pass
1163 /// `OutsideTap::Notify { consume: false }` for the pass-through shape.
1164 pub fn on_outside_tap(mut self, callback: impl Fn(&mut State) + 'static) -> Self {
1165 self.on_outside_tap = Some(Rc::new(callback));
1166 if self.outside_tap == OutsideTap::Ignore {
1167 self.outside_tap = OutsideTap::Notify { consume: true };
1168 }
1169 self
1170 }
1171
1172 /// Whether the surface claims declined keys (default [`OverlayKeys::Pass`];
1173 /// see [`OverlaySlot::set_keys`]).
1174 pub fn keys(mut self, keys: OverlayKeys) -> Self {
1175 self.keys = keys;
1176 self
1177 }
1178
1179 /// Run `callback` when a declined Escape reaches the surface (see
1180 /// [`OverlaySlot::take_escape`]) — the close hook of a modal surface, which
1181 /// then consumes the key.
1182 pub fn on_escape(mut self, callback: impl Fn(&mut State) + 'static) -> Self {
1183 self.on_escape = Some(Rc::new(callback));
1184 self
1185 }
1186
1187 /// Keep the child's focus session when the surface claims focus (default
1188 /// `false`).
1189 ///
1190 /// A surface that is *about* the child rather than a place to type — a
1191 /// selection toolbar over a field — must not take the field's focus, or the
1192 /// selection it acts on disappears the moment it is touched. With this set,
1193 /// a focus claim from inside the surface is withdrawn again whenever the
1194 /// child held the focus link, and focus-routed events keep reaching the
1195 /// child. Left `false`, a surface that claims focus gets it and the child's
1196 /// link inside this portal is dropped, which is what a popover containing
1197 /// its own text field wants.
1198 pub fn preserve_focus(mut self, preserve: bool) -> Self {
1199 self.preserve_focus = preserve;
1200 self
1201 }
1202}
1203
1204/// The retained widget for an [`OverlayPortalView`].
1205pub struct OverlayPortalWidget<State: 'static> {
1206 child: ChildPod,
1207 slot: OverlaySlot<State>,
1208 on_outside_tap: Option<ErasedCallback>,
1209 on_escape: Option<ErasedCallback>,
1210 preserve_focus: bool,
1211}
1212
1213impl<State: 'static> OverlayPortalView<State> {
1214 /// Push the view's routing configuration onto the slot — the half of
1215 /// `build`/`rebuild` that is identical in both.
1216 fn configure(&self, slot: &mut OverlaySlot<State>) {
1217 slot.set_placement(self.placement);
1218 slot.set_band(self.band);
1219 slot.set_input(self.input);
1220 slot.set_outside_tap(self.outside_tap);
1221 slot.set_keys(self.keys);
1222 }
1223}
1224
1225impl<State: 'static> View<State> for OverlayPortalView<State> {
1226 type Element = OverlayPortalWidget<State>;
1227
1228 fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayPortalWidget<State> {
1229 let mut slot = OverlaySlot::new();
1230 self.configure(&mut slot);
1231 slot.rebuild(None, self.overlay.as_ref(), ctx);
1232 OverlayPortalWidget {
1233 child: crate::authoring::build_child(&self.child, ctx),
1234 slot,
1235 on_outside_tap: self.on_outside_tap.as_ref().map(erase_callback),
1236 on_escape: self.on_escape.as_ref().map(erase_callback),
1237 preserve_focus: self.preserve_focus,
1238 }
1239 }
1240
1241 fn rebuild(
1242 &self,
1243 prev: &Self,
1244 element: &mut OverlayPortalWidget<State>,
1245 ctx: &mut BuildCtx<'_>,
1246 ) -> ChangeFlags {
1247 let mut flags = ChangeFlags::NONE;
1248 if prev.placement != self.placement
1249 || prev.band != self.band
1250 || prev.input != self.input
1251 || prev.outside_tap != self.outside_tap
1252 || prev.keys != self.keys
1253 {
1254 // Placement and the routing fields are re-read from the slot on the
1255 // next paint, which is also when the new rect is registered.
1256 flags |= ChangeFlags::PAINT;
1257 }
1258 self.configure(&mut element.slot);
1259 element.preserve_focus = self.preserve_focus;
1260 // Closures are not comparable, so the adapter is reinstalled
1261 // unconditionally — it is cheap.
1262 element.on_outside_tap = self.on_outside_tap.as_ref().map(erase_callback);
1263 element.on_escape = self.on_escape.as_ref().map(erase_callback);
1264 flags |= element
1265 .slot
1266 .rebuild(prev.overlay.as_ref(), self.overlay.as_ref(), ctx);
1267 flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
1268 flags
1269 }
1270
1271 fn teardown(&self, element: &mut OverlayPortalWidget<State>, ctx: &mut BuildCtx<'_>) {
1272 element.slot.rebuild(self.overlay.as_ref(), None, ctx);
1273 crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
1274 }
1275}
1276
1277impl<State: 'static> Widget for OverlayPortalWidget<State> {
1278 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1279 let size = self.child.layout_child(ctx, bc);
1280 self.child.set_origin(Point::ZERO);
1281 // The floated pod is sized against the window, not against `bc` — it
1282 // escapes this widget's box entirely.
1283 self.slot.layout(ctx);
1284 bc.constrain(size)
1285 }
1286
1287 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1288 self.child.paint_child(ctx, scene);
1289 // Registered, never painted here: the root paints it after the whole
1290 // main tree, which is what puts it above a later sibling.
1291 let size = ctx.size();
1292 self.slot.paint(ctx, size);
1293 }
1294
1295 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1296 let child_focused = self.child.holds_live_focus();
1297 // A focus-routed event belongs to the child whenever the child holds the
1298 // live link. Both links can be live at once only through
1299 // `preserve_focus`, which deliberately re-records the child's against the
1300 // same session it hands back; otherwise exactly one of them is, and this
1301 // check simply prefers the main tree when both are.
1302 if !(event.is_focus_routed() && child_focused) {
1303 let pod_focused_before = self.slot.pod_has_focus();
1304 if let Some(result) = self.slot.event_ambient(ctx, event) {
1305 // Drained unconditionally: a notification with no callback
1306 // installed is still spent, not left standing for a later pass.
1307 if self.slot.take_outside_down()
1308 && let Some(callback) = &mut self.on_outside_tap
1309 {
1310 callback(ctx);
1311 ctx.request_redraw();
1312 }
1313 if self.slot.take_escape()
1314 && let Some(callback) = &mut self.on_escape
1315 {
1316 callback(ctx);
1317 ctx.consume_overlay_key();
1318 ctx.request_redraw();
1319 }
1320 if !pod_focused_before && self.slot.pod_has_focus() {
1321 if self.preserve_focus && child_focused {
1322 // Hand the session back to the child: the surface's own
1323 // claim is withdrawn, and re-asserting the request keeps
1324 // every ancestor's recorded chain pointing here.
1325 //
1326 // The child's own link is re-recorded rather than left
1327 // alone. The surface's claim opened a new focus session,
1328 // and a link recorded against the session before it is
1329 // not a link on this one — so handing the session back
1330 // means saying so on the child's own record, not merely
1331 // not clearing it.
1332 self.slot.withdraw_pod_focus();
1333 self.child.set_focused(true);
1334 ctx.request_focus();
1335 } else {
1336 // The surface took the session, so the child's link
1337 // inside this portal is stale — an overlay press never
1338 // reaches the root's own blur rule to clear it.
1339 self.child.set_focused(false);
1340 }
1341 }
1342 return result;
1343 }
1344 }
1345 route_event_single(&mut self.child, ctx, event)
1346 }
1347
1348 fn semantics(&self, ctx: &mut SemanticsCtx) {
1349 // The child only: a floated pod's nodes would attach at this widget's
1350 // position rather than the surface's (see the module docs).
1351 self.child.semantics_child(ctx);
1352 }
1353
1354 crate::authoring::visit_children!(child);
1355
1356 // The pod is visited after the child, so Tab reaches the owner's own stops
1357 // first. Not `is_focusable` forwarding: the walk would then stop on the
1358 // portal and its child for one control, and the phantom stop's grant
1359 // would leave focus where it was.
1360 fn visit_focus_children(&self, visitor: &mut dyn FnMut(&ChildPod)) {
1361 visitor(&self.child);
1362 self.slot.visit_focus_pod(visitor);
1363 }
1364}
1365
1366#[cfg(test)]
1367mod tests {
1368 use super::*;
1369 use crate::test_support::RecordingScene;
1370 use crate::{SizedBox, StackView, column, scroll_view, stack};
1371 use frust_core::{
1372 EditCommand, FrameTime, Key, KeyEvent, Modifiers, NamedKey, PointerButton, PointerPhase,
1373 RenderRoot, ScrollDelta,
1374 };
1375 use peniko::Color;
1376
1377 // -----------------------------------------------------------------------
1378 // Placement
1379 // -----------------------------------------------------------------------
1380
1381 const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 600.0);
1382
1383 /// Placement with the padding switched off, so a geometry assertion reads
1384 /// against the raw area rather than the inset one.
1385 fn bare() -> OverlayPlacement {
1386 OverlayPlacement::default().padding(0.0)
1387 }
1388
1389 fn anchor_rect(x: f64, y: f64, w: f64, h: f64) -> Rect {
1390 Rect::from_origin_size(Point::new(x, y), Size::new(w, h))
1391 }
1392
1393 #[test]
1394 fn the_default_placement_is_below_the_anchor_centred_at_the_neutral_gap() {
1395 let p = OverlayPlacement::default();
1396 assert_eq!(p.side, OverlaySide::Bottom);
1397 assert_eq!(p.align, OverlayAlign::Center);
1398 assert_eq!(p.offset, DEFAULT_OFFSET);
1399 assert_eq!(p.padding, DEFAULT_PADDING);
1400 assert!(p.flip && p.clamp);
1401 }
1402
1403 #[test]
1404 fn each_side_offsets_off_its_own_edge() {
1405 let a = anchor_rect(100.0, 200.0, 80.0, 40.0);
1406 let content = Size::new(120.0, 60.0);
1407 // The raw side placement, with the collision passes off: `Left` would
1408 // otherwise flip (a 120px panel does not fit in the 100px to the
1409 // anchor's left), which the flip test below covers on its own.
1410 let raw = |side| OverlayPlacement::on(side).flip(false).clamp(false);
1411 let bottom = place(a, content, AREA, raw(OverlaySide::Bottom));
1412 assert_eq!(bottom.y0, a.y1 + DEFAULT_OFFSET);
1413 let top = place(a, content, AREA, raw(OverlaySide::Top));
1414 assert_eq!(top.y1, a.y0 - DEFAULT_OFFSET);
1415 let right = place(a, content, AREA, raw(OverlaySide::Right));
1416 assert_eq!(right.x0, a.x1 + DEFAULT_OFFSET);
1417 let left = place(a, content, AREA, raw(OverlaySide::Left));
1418 assert_eq!(left.x1, a.x0 - DEFAULT_OFFSET);
1419 // Sizes are never altered by placement.
1420 for r in [bottom, top, right, left] {
1421 assert_eq!(r.size(), content);
1422 }
1423 assert!(OverlaySide::Top.is_vertical() && !OverlaySide::Left.is_vertical());
1424 assert_eq!(OverlaySide::Top.opposite(), OverlaySide::Bottom);
1425 assert_eq!(OverlaySide::Left.opposite(), OverlaySide::Right);
1426 }
1427
1428 #[test]
1429 fn align_lines_up_leading_center_or_trailing_edges() {
1430 let a = anchor_rect(100.0, 200.0, 80.0, 40.0);
1431 let content = Size::new(120.0, 60.0);
1432 let at = |side, align| place(a, content, AREA, OverlayPlacement::on(side).align(align));
1433 assert_eq!(at(OverlaySide::Bottom, OverlayAlign::Start).x0, a.x0);
1434 assert_eq!(at(OverlaySide::Bottom, OverlayAlign::End).x1, a.x1);
1435 assert_eq!(
1436 at(OverlaySide::Bottom, OverlayAlign::Center).center().x,
1437 a.center().x
1438 );
1439
1440 // The same three on a horizontal side act on the vertical axis.
1441 assert_eq!(at(OverlaySide::Right, OverlayAlign::Start).y0, a.y0);
1442 assert_eq!(at(OverlaySide::Right, OverlayAlign::End).y1, a.y1);
1443 assert_eq!(
1444 at(OverlaySide::Right, OverlayAlign::Center).center().y,
1445 a.center().y
1446 );
1447 }
1448
1449 #[test]
1450 fn a_side_that_does_not_fit_flips_to_the_opposite_one() {
1451 // An anchor near the bottom edge: `Bottom` overflows, `Top` fits.
1452 let a = anchor_rect(100.0, 560.0, 80.0, 20.0);
1453 let content = Size::new(120.0, 100.0);
1454 let flipped = place(a, content, AREA, bare());
1455 assert_eq!(
1456 flipped.y1,
1457 a.y0 - DEFAULT_OFFSET,
1458 "flipped above the anchor"
1459 );
1460
1461 // With the flip disabled it stays below and only the clamp moves it.
1462 let pinned = place(a, content, AREA, bare().flip(false));
1463 assert_eq!(pinned.y1, AREA.y1, "clamped, not flipped");
1464
1465 // Neither side fits: the preferred one is kept (and clamped).
1466 let tall = Size::new(120.0, 590.0);
1467 let kept = place(a, tall, AREA, bare());
1468 assert_eq!(kept.y1, AREA.y1);
1469 }
1470
1471 #[test]
1472 fn each_side_flips_at_the_edge_it_would_overflow() {
1473 let content = Size::new(120.0, 100.0);
1474 // Top edge: a `Top` placement flips down.
1475 let high = anchor_rect(100.0, 10.0, 80.0, 20.0);
1476 let down = place(high, content, AREA, OverlayPlacement::on(OverlaySide::Top));
1477 assert_eq!(down.y0, high.y1 + DEFAULT_OFFSET);
1478 // Left edge: a `Left` placement flips right.
1479 let leading = anchor_rect(10.0, 200.0, 20.0, 20.0);
1480 let right = place(
1481 leading,
1482 content,
1483 AREA,
1484 OverlayPlacement::on(OverlaySide::Left),
1485 );
1486 assert_eq!(right.x0, leading.x1 + DEFAULT_OFFSET);
1487 // Right edge: a `Right` placement flips left.
1488 let trailing = anchor_rect(370.0, 200.0, 20.0, 20.0);
1489 let left = place(
1490 trailing,
1491 content,
1492 AREA,
1493 OverlayPlacement::on(OverlaySide::Right),
1494 );
1495 assert_eq!(left.x1, trailing.x0 - DEFAULT_OFFSET);
1496 }
1497
1498 #[test]
1499 fn clamping_shifts_the_rect_back_inside_and_can_be_turned_off() {
1500 // An anchor at the right edge, centre-aligned: the panel overflows.
1501 let a = anchor_rect(380.0, 100.0, 20.0, 20.0);
1502 let content = Size::new(200.0, 50.0);
1503 let clamped = place(a, content, AREA, bare());
1504 assert_eq!(clamped.x1, AREA.x1);
1505 assert_eq!(
1506 clamped.y0,
1507 a.y1 + DEFAULT_OFFSET,
1508 "only the cross axis moved"
1509 );
1510
1511 let free = place(a, content, AREA, bare().clamp(false));
1512 assert!(free.x1 > AREA.x1, "unclamped placement may overflow");
1513
1514 // Content wider than the area pins to the leading edge (the start-edge
1515 // rule), not the trailing one.
1516 let huge = Size::new(600.0, 50.0);
1517 let pinned = place(a, huge, AREA, bare());
1518 assert_eq!(pinned.x0, AREA.x0);
1519 }
1520
1521 #[test]
1522 fn the_clamp_keeps_the_padding_off_every_edge() {
1523 let content = Size::new(200.0, 50.0);
1524 // Trailing overflow lands `DEFAULT_PADDING` short of the area edge.
1525 let trailing = place(
1526 anchor_rect(380.0, 100.0, 20.0, 20.0),
1527 content,
1528 AREA,
1529 OverlayPlacement::default(),
1530 );
1531 assert_eq!(trailing.x1, AREA.x1 - DEFAULT_PADDING);
1532 // …and so does a leading one.
1533 let leading = place(
1534 anchor_rect(0.0, 100.0, 20.0, 20.0),
1535 content,
1536 AREA,
1537 OverlayPlacement::default(),
1538 );
1539 assert_eq!(leading.x0, AREA.x0 + DEFAULT_PADDING);
1540 // The bottom edge is the same rule on the other axis.
1541 let low = place(
1542 anchor_rect(100.0, 560.0, 20.0, 20.0),
1543 Size::new(100.0, 300.0),
1544 AREA,
1545 OverlayPlacement::default().flip(false),
1546 );
1547 assert_eq!(low.y1, AREA.y1 - DEFAULT_PADDING);
1548 }
1549
1550 #[test]
1551 fn a_padding_larger_than_the_area_falls_back_to_the_area_itself() {
1552 // A window narrower than twice the padding still has to place its
1553 // surface somewhere; the inset would invert, so it is dropped.
1554 let tiny = Rect::new(0.0, 0.0, 10.0, 10.0);
1555 let rect = place(
1556 Rect::ZERO,
1557 Size::new(4.0, 4.0),
1558 tiny,
1559 OverlayPlacement::default().flip(false),
1560 );
1561 assert!(tiny.contains(rect.origin()));
1562 assert_eq!(rect.x0, 0.0, "clamped to the un-inset area's own edge");
1563 }
1564
1565 #[test]
1566 fn a_degenerate_anchor_places_against_that_point() {
1567 let content = Size::new(80.0, 40.0);
1568 let rect = place(Rect::ZERO, content, AREA, OverlayPlacement::default());
1569 assert_eq!(
1570 rect.x0,
1571 AREA.x0 + DEFAULT_PADDING,
1572 "clamped in from the centred overhang"
1573 );
1574 // The neutral gap is smaller than the padding, so the same clamp holds
1575 // the surface off the top edge as well.
1576 assert_eq!(rect.y0, AREA.y0 + DEFAULT_PADDING);
1577 }
1578
1579 // -----------------------------------------------------------------------
1580 // The portal, driven through a real render root
1581 // -----------------------------------------------------------------------
1582
1583 /// Everything the fixture's widgets record, in the order it happened.
1584 type Log = Rc<RefCell<Vec<String>>>;
1585
1586 const WINDOW: Size = Size::new(400.0, 600.0);
1587
1588 /// Where the `Plain` fixture's surface lands: the top-left corner of the
1589 /// padded field, because a window-sized anchor fits its content on neither
1590 /// side and the clamp takes over.
1591 const POD: Rect = Rect::new(8.0, 8.0, 88.0, 38.0);
1592
1593 /// What the floated content does when it is pressed.
1594 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1595 enum Reaction {
1596 #[default]
1597 Nothing,
1598 Capture,
1599 Focus,
1600 /// Ask for a layout pass (`EventCtx::request_layout`).
1601 Layout,
1602 }
1603
1604 /// Which tree the fixture builds.
1605 #[derive(Clone, Copy, PartialEq, Eq)]
1606 enum Shape {
1607 /// A window-filling portal child with a later, window-filling sibling.
1608 Plain,
1609 /// A small portal child partway down a scrollable column.
1610 Scrolled,
1611 /// The portal beside a focus-taking field, both inside one `Column`, so
1612 /// the container reconciling them is the ordinary
1613 /// [`route_event`](crate::authoring::route_event) one and the field is a
1614 /// SIBLING of the portal rather than its child — the arrangement in
1615 /// which a recorded focus link the container never swept still decides
1616 /// where a focus-routed event goes.
1617 Sibling,
1618 }
1619
1620 #[derive(Clone, Copy)]
1621 struct Cfg {
1622 open: bool,
1623 shape: Shape,
1624 band: OverlayBand,
1625 input: OverlayInput,
1626 /// `None` leaves the policy to `on_outside_tap`'s own default.
1627 outside_tap: Option<OutsideTap>,
1628 keys: OverlayKeys,
1629 preserve_focus: bool,
1630 reaction: Reaction,
1631 placement: OverlayPlacement,
1632 }
1633
1634 /// The corner placement the `Plain` fixture pins its surface with — chosen
1635 /// so the rect is the same however the fixture is configured.
1636 fn corner() -> OverlayPlacement {
1637 OverlayPlacement::on(OverlaySide::Top)
1638 .offset(0.0)
1639 .align(OverlayAlign::Start)
1640 }
1641
1642 impl Default for Cfg {
1643 fn default() -> Self {
1644 Cfg {
1645 open: true,
1646 shape: Shape::Plain,
1647 band: OverlayBand::Floating,
1648 input: OverlayInput::Interactive,
1649 outside_tap: Some(OutsideTap::Ignore),
1650 keys: OverlayKeys::Pass,
1651 preserve_focus: false,
1652 reaction: Reaction::Nothing,
1653 placement: corner(),
1654 }
1655 }
1656 }
1657
1658 struct App {
1659 cfg: Cfg,
1660 log: Log,
1661 presses: u32,
1662 outside_taps: u32,
1663 escapes: u32,
1664 }
1665
1666 /// A recording leaf: paints one rect at its own absolute origin, records
1667 /// every event it receives (positions in its own local space) and reacts to
1668 /// a press the way the fixture asked it to.
1669 struct Probe {
1670 tag: &'static str,
1671 /// `None` fills whatever it is offered.
1672 size: Option<Size>,
1673 reaction: Reaction,
1674 /// Whether its pointer arms report `Handled` — a `false` leaf lets a
1675 /// press fall through to the child painted under it.
1676 handles: bool,
1677 log: Log,
1678 }
1679
1680 struct ProbeWidget {
1681 tag: &'static str,
1682 size: Option<Size>,
1683 reaction: Reaction,
1684 handles: bool,
1685 log: Log,
1686 }
1687
1688 impl View<App> for Probe {
1689 type Element = ProbeWidget;
1690 fn build(&self, _ctx: &mut BuildCtx<'_>) -> ProbeWidget {
1691 ProbeWidget {
1692 tag: self.tag,
1693 size: self.size,
1694 reaction: self.reaction,
1695 handles: self.handles,
1696 log: Rc::clone(&self.log),
1697 }
1698 }
1699 fn rebuild(
1700 &self,
1701 _prev: &Self,
1702 element: &mut ProbeWidget,
1703 _ctx: &mut BuildCtx<'_>,
1704 ) -> ChangeFlags {
1705 element.size = self.size;
1706 element.reaction = self.reaction;
1707 element.handles = self.handles;
1708 element.log = Rc::clone(&self.log);
1709 ChangeFlags::NONE
1710 }
1711 }
1712
1713 impl Widget for ProbeWidget {
1714 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1715 bc.constrain(self.size.unwrap_or_else(|| bc.max()))
1716 }
1717 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1718 scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
1719 }
1720 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1721 match event {
1722 InputEvent::Pointer(p) => {
1723 self.log.borrow_mut().push(format!(
1724 "{}:{:?}@{},{}",
1725 self.tag, p.phase, p.position.x, p.position.y
1726 ));
1727 if p.phase == PointerPhase::Down {
1728 ctx.state_mut::<App>().presses += 1;
1729 match self.reaction {
1730 // A press this leaf only records: the commonest
1731 // shape, and the one that must not disturb focus.
1732 Reaction::Nothing => {}
1733 Reaction::Capture => ctx.capture_pointer(),
1734 Reaction::Focus => ctx.request_focus(),
1735 Reaction::Layout => ctx.request_layout(),
1736 }
1737 }
1738 if self.handles {
1739 EventResult::Handled
1740 } else {
1741 EventResult::Ignored
1742 }
1743 }
1744 InputEvent::Key(_) => {
1745 self.log.borrow_mut().push(format!(
1746 "{}:key focus={}",
1747 self.tag,
1748 ctx.has_focus()
1749 ));
1750 if ctx.has_focus() {
1751 EventResult::Handled
1752 } else {
1753 EventResult::Ignored
1754 }
1755 }
1756 _ => EventResult::Ignored,
1757 }
1758 }
1759 }
1760
1761 /// The fixture's whole view tree, rebuilt from the app state every frame so
1762 /// a test can reconfigure it between frames.
1763 fn logic(state: &mut App) -> StackView<App> {
1764 let cfg = state.cfg;
1765 let log = Rc::clone(&state.log);
1766 let pod = cfg.open.then(|| {
1767 any(Probe {
1768 tag: "pod",
1769 size: Some(Size::new(80.0, 30.0)),
1770 reaction: cfg.reaction,
1771 handles: true,
1772 log: Rc::clone(&log),
1773 })
1774 });
1775 // The child claims focus on a press, so it stands in for the focused
1776 // field a surface is opened over.
1777 let child = Probe {
1778 tag: "child",
1779 size: match cfg.shape {
1780 Shape::Plain => None,
1781 Shape::Scrolled => Some(Size::new(120.0, 40.0)),
1782 Shape::Sibling => Some(Size::new(400.0, 50.0)),
1783 },
1784 reaction: Reaction::Focus,
1785 handles: true,
1786 log: Rc::clone(&log),
1787 };
1788 let mut portal = overlay_portal(child)
1789 .overlay(pod)
1790 .placement(cfg.placement)
1791 .band(cfg.band)
1792 .input(cfg.input)
1793 .preserve_focus(cfg.preserve_focus)
1794 .keys(cfg.keys)
1795 .on_escape(|state: &mut App| state.escapes += 1)
1796 .on_outside_tap(|state: &mut App| state.outside_taps += 1);
1797 if let Some(policy) = cfg.outside_tap {
1798 portal = portal.outside_tap(policy);
1799 }
1800 match cfg.shape {
1801 Shape::Plain => stack()
1802 .child(portal)
1803 // Painted after the portal and covering it, so "the pod paints
1804 // above a later sibling" is a real question. It handles nothing,
1805 // so a press falls through to the portal's own child.
1806 .child(Probe {
1807 tag: "sib",
1808 size: None,
1809 reaction: Reaction::Nothing,
1810 handles: false,
1811 log,
1812 }),
1813 Shape::Scrolled => stack().child(scroll_view(
1814 column()
1815 .child(SizedBox(Some(400.0), Some(200.0)))
1816 .child(portal)
1817 .child(SizedBox(Some(400.0), Some(1000.0))),
1818 )),
1819 // The field first, the portal second: a focus-routed event walking
1820 // the children in order meets the field's link before the portal's.
1821 Shape::Sibling => stack().child(
1822 column()
1823 .child(Probe {
1824 tag: "field",
1825 size: Some(Size::new(400.0, 50.0)),
1826 reaction: Reaction::Focus,
1827 handles: true,
1828 log,
1829 })
1830 .child(portal),
1831 ),
1832 }
1833 }
1834
1835 struct Harness {
1836 root: RenderRoot<App, StackView<App>>,
1837 state: App,
1838 clock_ms: f64,
1839 }
1840
1841 impl Harness {
1842 fn new(cfg: Cfg) -> Self {
1843 Harness {
1844 root: RenderRoot::new(),
1845 state: App {
1846 cfg,
1847 log: Rc::new(RefCell::new(Vec::new())),
1848 presses: 0,
1849 outside_taps: 0,
1850 escapes: 0,
1851 },
1852 clock_ms: 0.0,
1853 }
1854 }
1855
1856 /// One whole frame: rebuild, layout, paint — returning what was painted,
1857 /// in paint order.
1858 fn frame(&mut self) -> RecordingScene {
1859 let mut build: fn(&mut App) -> StackView<App> = logic;
1860 self.root.rebuild(&mut build, &mut self.state);
1861 self.root.layout(WINDOW);
1862 self.clock_ms += 16.0;
1863 let mut scene = RecordingScene::default();
1864 self.root.paint(
1865 &mut scene,
1866 FrameTime::from_nanos((self.clock_ms * 1_000_000.0) as u64),
1867 );
1868 scene
1869 }
1870
1871 fn pointer(&mut self, phase: PointerPhase, x: f64, y: f64) {
1872 self.root.event(
1873 &mut self.state,
1874 &InputEvent::Pointer(PointerEvent {
1875 phase,
1876 position: Point::new(x, y),
1877 button: PointerButton::Primary,
1878 }),
1879 );
1880 }
1881
1882 fn down(&mut self, x: f64, y: f64) {
1883 self.pointer(PointerPhase::Down, x, y);
1884 }
1885
1886 fn key(&mut self) {
1887 self.root.event(
1888 &mut self.state,
1889 &InputEvent::Key(KeyEvent {
1890 key: Key::Named(NamedKey::ArrowLeft),
1891 modifiers: Modifiers::default(),
1892 repeat: false,
1893 }),
1894 );
1895 }
1896
1897 fn press(&mut self, key: NamedKey) -> frust_core::EventOutcome {
1898 let event = KeyEvent {
1899 key: Key::Named(key),
1900 modifiers: Modifiers::default(),
1901 repeat: false,
1902 };
1903 self.root.event(&mut self.state, &InputEvent::Key(event))
1904 }
1905
1906 fn log(&self) -> Vec<String> {
1907 self.state.log.borrow().clone()
1908 }
1909
1910 fn clear_log(&mut self) {
1911 self.state.log.borrow_mut().clear();
1912 }
1913 }
1914
1915 /// The rects a scene recorded, so a paint-order assertion reads as geometry.
1916 fn rects(scene: &RecordingScene) -> Vec<Rect> {
1917 scene
1918 .rects
1919 .iter()
1920 .map(|(origin, size)| Rect::from_origin_size(*origin, *size))
1921 .collect()
1922 }
1923
1924 #[test]
1925 fn the_overlay_pod_paints_above_a_later_sibling() {
1926 let mut h = Harness::new(Cfg::default());
1927 let scene = h.frame();
1928 let painted = rects(&scene);
1929 assert_eq!(
1930 painted.last().copied(),
1931 Some(POD),
1932 "the floated pod paints after the whole main tree, not in place: {painted:?}"
1933 );
1934 // …and it really is covered in the main tree: the sibling painted over
1935 // the same pixels one step earlier.
1936 let sibling = painted[painted.len() - 2];
1937 assert_eq!(sibling, Rect::from_origin_size(Point::ZERO, WINDOW));
1938 }
1939
1940 #[test]
1941 fn a_press_inside_the_surface_reaches_the_pod_in_its_own_space() {
1942 let mut h = Harness::new(Cfg::default());
1943 h.frame();
1944 h.clear_log();
1945 h.down(40.0, 20.0);
1946 assert_eq!(
1947 h.log(),
1948 vec!["pod:Down@32,12".to_string()],
1949 "the press is delivered in pod space (window minus the placed origin)"
1950 );
1951 assert_eq!(
1952 h.state.presses, 1,
1953 "the pod's own callback ran on app state"
1954 );
1955 }
1956
1957 #[test]
1958 fn a_transparent_tooltip_surface_never_receives_input() {
1959 let mut h = Harness::new(Cfg {
1960 band: OverlayBand::Tooltip,
1961 input: OverlayInput::Transparent,
1962 ..Cfg::default()
1963 });
1964 let scene = h.frame();
1965 assert_eq!(
1966 rects(&scene).last().copied(),
1967 Some(POD),
1968 "a transparent surface is still painted above everything"
1969 );
1970 h.clear_log();
1971 h.down(40.0, 20.0);
1972 assert_eq!(
1973 h.log(),
1974 vec!["sib:Down@40,20".to_string(), "child:Down@40,20".to_string()],
1975 "the pointer passes through to the main tree, topmost sibling first"
1976 );
1977 }
1978
1979 #[test]
1980 fn a_drag_begun_on_the_surface_continues_into_it_through_the_capture() {
1981 // Deliberately the scrolled tree: the owner sits 200px down, so the
1982 // follow-ups — which arrive in the OWNER's local space, not the
1983 // window's — are wrong by exactly that much unless they are lifted.
1984 let mut h = Harness::new(Cfg {
1985 shape: Shape::Scrolled,
1986 placement: OverlayPlacement::default(),
1987 reaction: Reaction::Capture,
1988 ..Cfg::default()
1989 });
1990 h.frame();
1991 h.clear_log();
1992 h.down(30.0, 250.0);
1993 assert!(
1994 h.root.is_pointer_captured(),
1995 "a capture claimed from inside the surface is honoured"
1996 );
1997 // Far outside the placed rect, and outside the owner too: the capture,
1998 // not the hit test, is what routes these.
1999 h.pointer(PointerPhase::Move, 200.0, 400.0);
2000 h.pointer(PointerPhase::Up, 210.0, 410.0);
2001 assert_eq!(
2002 h.log(),
2003 vec![
2004 "pod:Down@10,6".to_string(),
2005 "pod:Move@180,156".to_string(),
2006 "pod:Up@190,166".to_string(),
2007 ]
2008 );
2009 assert!(!h.root.is_pointer_captured(), "the Up releases the capture");
2010 // The gesture is over: a further move outside the surface is nobody's.
2011 h.clear_log();
2012 h.pointer(PointerPhase::Move, 200.0, 400.0);
2013 assert!(
2014 !h.log().iter().any(|e| e.starts_with("pod:")),
2015 "a move after the release is no longer the surface's: {:?}",
2016 h.log()
2017 );
2018 }
2019
2020 /// The whole gesture, in the log shape the fixture records it in: pressed
2021 /// inside the placed rect, dragged well outside it, released there.
2022 fn one_gesture(h: &mut Harness, label: &str) {
2023 h.clear_log();
2024 h.down(40.0, 20.0);
2025 assert!(
2026 h.root.is_pointer_captured(),
2027 "{label}: the capture claimed inside the surface opened a gesture"
2028 );
2029 h.pointer(PointerPhase::Move, 300.0, 300.0);
2030 h.pointer(PointerPhase::Up, 300.0, 300.0);
2031 assert_eq!(
2032 h.log(),
2033 vec![
2034 "pod:Down@32,12".to_string(),
2035 "pod:Move@292,292".to_string(),
2036 "pod:Up@292,292".to_string(),
2037 ],
2038 "{label}: every phase of the gesture reached the surface"
2039 );
2040 assert!(
2041 !h.root.is_pointer_captured(),
2042 "{label}: the Up released the capture"
2043 );
2044 }
2045
2046 #[test]
2047 fn a_second_gesture_into_the_same_mounted_pod_routes_exactly_like_the_first() {
2048 let mut h = Harness::new(Cfg {
2049 reaction: Reaction::Capture,
2050 ..Cfg::default()
2051 });
2052 h.frame();
2053 // No frame between the two: the pod is the same widget instance, so the
2054 // second press meets whatever the first gesture left recorded on it.
2055 one_gesture(&mut h, "first gesture");
2056 one_gesture(&mut h, "second gesture");
2057 }
2058
2059 #[test]
2060 fn a_cancelled_gesture_leaves_the_surface_ready_for_the_next_one() {
2061 let mut h = Harness::new(Cfg {
2062 reaction: Reaction::Capture,
2063 ..Cfg::default()
2064 });
2065 h.frame();
2066 h.down(40.0, 20.0);
2067 h.pointer(PointerPhase::Cancel, 300.0, 300.0);
2068 assert!(
2069 !h.root.is_pointer_captured(),
2070 "a Cancel ends the gesture exactly as an Up does"
2071 );
2072 one_gesture(&mut h, "the gesture after a cancel");
2073 }
2074
2075 #[test]
2076 fn a_press_outside_notifies_the_owner_and_can_still_reach_the_main_tree() {
2077 let mut h = Harness::new(Cfg {
2078 outside_tap: Some(OutsideTap::Notify { consume: false }),
2079 ..Cfg::default()
2080 });
2081 h.frame();
2082 h.clear_log();
2083 h.down(200.0, 400.0);
2084 assert_eq!(h.state.outside_taps, 1, "the owner was told");
2085 assert_eq!(
2086 h.log(),
2087 vec![
2088 "sib:Down@200,400".to_string(),
2089 "child:Down@200,400".to_string()
2090 ],
2091 "a pass-through notification still lets the press through"
2092 );
2093 }
2094
2095 #[test]
2096 fn a_consuming_outside_press_is_swallowed() {
2097 let mut h = Harness::new(Cfg {
2098 outside_tap: Some(OutsideTap::Notify { consume: true }),
2099 ..Cfg::default()
2100 });
2101 h.frame();
2102 h.clear_log();
2103 h.down(200.0, 400.0);
2104 assert_eq!(h.state.outside_taps, 1);
2105 assert!(
2106 h.log().is_empty(),
2107 "the dismissing press never reached the main tree: {:?}",
2108 h.log()
2109 );
2110 }
2111
2112 #[test]
2113 fn an_outside_tap_callback_opts_into_the_notification_by_itself() {
2114 // No explicit policy: installing the callback is what asks to hear.
2115 let mut h = Harness::new(Cfg {
2116 outside_tap: None,
2117 ..Cfg::default()
2118 });
2119 h.frame();
2120 h.clear_log();
2121 h.down(200.0, 400.0);
2122 assert_eq!(h.state.outside_taps, 1);
2123 assert!(
2124 h.log().is_empty(),
2125 "the implied policy is the modal one (consuming): {:?}",
2126 h.log()
2127 );
2128 }
2129
2130 #[test]
2131 fn an_ignoring_surface_hears_nothing_about_an_outside_press() {
2132 let mut h = Harness::new(Cfg::default());
2133 h.frame();
2134 h.clear_log();
2135 h.down(200.0, 400.0);
2136 assert_eq!(h.state.outside_taps, 0);
2137 assert_eq!(
2138 h.log(),
2139 vec![
2140 "sib:Down@200,400".to_string(),
2141 "child:Down@200,400".to_string()
2142 ]
2143 );
2144 }
2145
2146 #[test]
2147 fn a_declined_escape_reaches_a_modal_surfaces_on_escape_and_stops_there() {
2148 let mut h = Harness::new(Cfg {
2149 keys: OverlayKeys::Modal,
2150 ..Cfg::default()
2151 });
2152 h.frame();
2153 assert!(h.press(NamedKey::Escape).handled, "on_escape consumed it");
2154 assert_eq!(h.state.escapes, 1);
2155 assert!(!h.press(NamedKey::ArrowLeft).handled, "only Escape closes");
2156 assert_eq!(h.state.escapes, 1);
2157
2158 let mut h = Harness::new(Cfg::default());
2159 h.frame();
2160 assert!(!h.press(NamedKey::Escape).handled);
2161 assert_eq!(h.state.escapes, 0, "a non-modal surface hears nothing");
2162 }
2163
2164 #[test]
2165 fn a_surface_that_claims_focus_takes_it_from_the_child() {
2166 let mut h = Harness::new(Cfg {
2167 reaction: Reaction::Focus,
2168 preserve_focus: false,
2169 ..Cfg::default()
2170 });
2171 h.frame();
2172 // The child is focused first, exactly as a field is before its surface
2173 // opens over it.
2174 h.down(200.0, 400.0);
2175 assert!(h.root.is_focus_active());
2176 h.clear_log();
2177 h.key();
2178 assert_eq!(h.log(), vec!["child:key focus=true".to_string()]);
2179
2180 // A press inside the surface, which claims focus for itself.
2181 h.clear_log();
2182 h.down(40.0, 20.0);
2183 h.key();
2184 assert_eq!(
2185 h.log(),
2186 vec![
2187 "pod:Down@32,12".to_string(),
2188 "pod:key focus=true".to_string()
2189 ],
2190 "the keyboard follows the surface"
2191 );
2192 assert!(h.root.is_focus_active());
2193 }
2194
2195 #[test]
2196 fn preserve_focus_hands_the_session_back_to_the_child() {
2197 let mut h = Harness::new(Cfg {
2198 reaction: Reaction::Focus,
2199 preserve_focus: true,
2200 ..Cfg::default()
2201 });
2202 h.frame();
2203 h.down(200.0, 400.0);
2204 h.clear_log();
2205 h.down(40.0, 20.0);
2206 h.key();
2207 assert_eq!(
2208 h.log(),
2209 vec![
2210 "pod:Down@32,12".to_string(),
2211 "child:key focus=true".to_string()
2212 ],
2213 "the field that opened the surface keeps typing"
2214 );
2215 assert!(h.root.is_focus_active(), "and keeps its session");
2216 }
2217
2218 #[test]
2219 fn a_child_that_takes_focus_back_gets_the_keyboard_again() {
2220 let mut h = Harness::new(Cfg {
2221 reaction: Reaction::Focus,
2222 preserve_focus: false,
2223 ..Cfg::default()
2224 });
2225 h.frame();
2226 // The child is focused, the surface then takes the session…
2227 h.down(200.0, 400.0);
2228 h.down(40.0, 20.0);
2229 // …and the child is pressed again, which re-claims it.
2230 h.down(200.0, 400.0);
2231 h.clear_log();
2232 h.key();
2233 assert_eq!(
2234 h.log(),
2235 vec!["child:key focus=true".to_string()],
2236 "the surface's older link must not outrank the main tree's live one"
2237 );
2238 }
2239
2240 /// A focus link the container never swept must stop deciding where a
2241 /// focus-routed event goes.
2242 ///
2243 /// The field and the portal are siblings here, so the surface's claim
2244 /// arrives as a broadcast the container forwards to both — no hit test, and
2245 /// therefore no blur-on-outside-tap sweep to clear the field's link. Both
2246 /// children then read as focus-link holders, and a container that answers
2247 /// "which child holds focus" with the first one in child order delivers the
2248 /// keyboard to the field the user has left.
2249 #[test]
2250 fn a_stale_sibling_link_does_not_outrank_the_surface_that_took_the_session() {
2251 let mut h = Harness::new(Cfg {
2252 shape: Shape::Sibling,
2253 reaction: Reaction::Focus,
2254 placement: OverlayPlacement::on(OverlaySide::Bottom)
2255 .offset(0.0)
2256 .align(OverlayAlign::Start)
2257 .padding(0.0),
2258 ..Cfg::default()
2259 });
2260 h.frame();
2261
2262 // The field takes the session.
2263 h.down(200.0, 25.0);
2264 h.clear_log();
2265 h.key();
2266 assert_eq!(h.log(), vec!["field:key focus=true".to_string()]);
2267
2268 // A press inside the surface, which claims focus for itself. The
2269 // container sees only the broadcast, so it sweeps nothing.
2270 h.down(40.0, 110.0);
2271 h.clear_log();
2272 h.key();
2273 assert_eq!(
2274 h.log(),
2275 vec!["pod:key focus=true".to_string()],
2276 "the keyboard follows the branch that actually holds the session"
2277 );
2278 }
2279
2280 #[test]
2281 fn a_surface_that_claims_nothing_never_disturbs_the_focused_child() {
2282 let mut h = Harness::new(Cfg::default());
2283 h.frame();
2284 h.down(200.0, 400.0);
2285 h.clear_log();
2286 h.down(40.0, 20.0);
2287 h.key();
2288 assert_eq!(
2289 h.log(),
2290 vec![
2291 "pod:Down@32,12".to_string(),
2292 "child:key focus=true".to_string()
2293 ],
2294 "an overlay press is not a blur"
2295 );
2296 }
2297
2298 #[test]
2299 fn scrolling_an_ancestor_moves_the_surface_on_the_next_paint() {
2300 let mut h = Harness::new(Cfg {
2301 shape: Shape::Scrolled,
2302 placement: OverlayPlacement::default(),
2303 ..Cfg::default()
2304 });
2305 let before = rects(&h.frame());
2306 assert_eq!(
2307 before.last().copied(),
2308 Some(Rect::new(20.0, 244.0, 100.0, 274.0)),
2309 "placed under its anchor, 200px down the scrolled column"
2310 );
2311 h.root.event(
2312 &mut h.state,
2313 &InputEvent::Scroll {
2314 position: Point::new(10.0, 10.0),
2315 delta: ScrollDelta::Pixels(0.0, 50.0),
2316 },
2317 );
2318 let after = rects(&h.frame());
2319 assert_eq!(
2320 after.last().copied(),
2321 Some(Rect::new(20.0, 194.0, 100.0, 224.0)),
2322 "the anchor moved with the scroll, and so did the surface"
2323 );
2324 // …and routing followed it: the pod's own rect is where the press lands.
2325 h.clear_log();
2326 h.down(30.0, 200.0);
2327 assert_eq!(h.log(), vec!["pod:Down@10,6".to_string()]);
2328 }
2329
2330 #[test]
2331 fn clearing_the_overlay_removes_it_after_the_next_paint() {
2332 let mut h = Harness::new(Cfg::default());
2333 assert_eq!(rects(&h.frame()).last().copied(), Some(POD));
2334 h.state.cfg.open = false;
2335 let scene = h.frame();
2336 assert!(
2337 !rects(&scene).contains(&POD),
2338 "nothing floated is painted once the view is gone: {:?}",
2339 rects(&scene)
2340 );
2341 h.clear_log();
2342 h.down(40.0, 20.0);
2343 assert_eq!(
2344 h.log(),
2345 vec!["sib:Down@40,20".to_string(), "child:Down@40,20".to_string()],
2346 "and the press reaches the main tree again"
2347 );
2348 }
2349
2350 // -----------------------------------------------------------------------
2351 // A slot over a `()`-typed pod: the framework-built-surface shape
2352 // -----------------------------------------------------------------------
2353
2354 /// An owner with no children of its own that hosts one `()`-typed surface,
2355 /// builds the floated view itself (as a widget mounting a framework-built
2356 /// surface does) and drains whatever the surface dispatched.
2357 struct ToolbarHost {
2358 log: Log,
2359 reaction: Reaction,
2360 }
2361
2362 struct ToolbarHostWidget {
2363 slot: OverlaySlot<()>,
2364 /// The mounted view, kept so the next rebuild has something to
2365 /// reconcile against.
2366 view: Option<AnyView<()>>,
2367 log: Log,
2368 reaction: Reaction,
2369 }
2370
2371 impl ToolbarHost {
2372 // erasure: keep result stored in an AnyView slot and element
2373 fn pod(&self) -> AnyView<()> {
2374 any(UnitProbe {
2375 log: Rc::clone(&self.log),
2376 reaction: self.reaction,
2377 })
2378 }
2379 }
2380
2381 impl View<()> for ToolbarHost {
2382 type Element = ToolbarHostWidget;
2383 fn build(&self, ctx: &mut BuildCtx<'_>) -> ToolbarHostWidget {
2384 let mut slot = OverlaySlot::new();
2385 slot.set_placement(corner());
2386 let view = self.pod();
2387 slot.rebuild(None, Some(&view), ctx);
2388 ToolbarHostWidget {
2389 slot,
2390 view: Some(view),
2391 log: Rc::clone(&self.log),
2392 reaction: self.reaction,
2393 }
2394 }
2395 fn rebuild(
2396 &self,
2397 _prev: &Self,
2398 element: &mut ToolbarHostWidget,
2399 ctx: &mut BuildCtx<'_>,
2400 ) -> ChangeFlags {
2401 element.log = Rc::clone(&self.log);
2402 element.reaction = self.reaction;
2403 let view = self.pod();
2404 let flags = element
2405 .slot
2406 .rebuild(element.view.as_ref(), Some(&view), ctx);
2407 element.view = Some(view);
2408 flags
2409 }
2410 }
2411
2412 impl Widget for ToolbarHostWidget {
2413 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2414 self.slot.layout(ctx);
2415 bc.constrain(Size::new(100.0, 40.0))
2416 }
2417 fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
2418 let size = ctx.size();
2419 self.slot.paint(ctx, size);
2420 }
2421 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2422 let mut pod_state = ();
2423 let Some(result) = self.slot.event(ctx, event, &mut pod_state) else {
2424 return EventResult::Ignored;
2425 };
2426 // Drained in the same pass the surface dispatched them in — the
2427 // queue is pass-scoped and is not a mailbox.
2428 for command in ctx.take_edit_commands() {
2429 self.log.borrow_mut().push(format!("cmd:{command:?}"));
2430 }
2431 result
2432 }
2433 }
2434
2435 /// The `()`-typed floated content: it carries no application state at all,
2436 /// and speaks to its owner through the edit-command queue.
2437 struct UnitProbe {
2438 log: Log,
2439 reaction: Reaction,
2440 }
2441
2442 struct UnitProbeWidget {
2443 log: Log,
2444 reaction: Reaction,
2445 }
2446
2447 impl View<()> for UnitProbe {
2448 type Element = UnitProbeWidget;
2449 fn build(&self, _ctx: &mut BuildCtx<'_>) -> UnitProbeWidget {
2450 UnitProbeWidget {
2451 log: Rc::clone(&self.log),
2452 reaction: self.reaction,
2453 }
2454 }
2455 fn rebuild(
2456 &self,
2457 _prev: &Self,
2458 element: &mut UnitProbeWidget,
2459 _ctx: &mut BuildCtx<'_>,
2460 ) -> ChangeFlags {
2461 element.log = Rc::clone(&self.log);
2462 element.reaction = self.reaction;
2463 ChangeFlags::NONE
2464 }
2465 }
2466
2467 impl Widget for UnitProbeWidget {
2468 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2469 bc.constrain(Size::new(80.0, 30.0))
2470 }
2471 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
2472 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2473 if let InputEvent::Pointer(p) = event {
2474 self.log.borrow_mut().push(format!(
2475 "pod:{:?}@{},{}",
2476 p.phase, p.position.x, p.position.y
2477 ));
2478 if p.phase == PointerPhase::Down {
2479 match self.reaction {
2480 Reaction::Nothing => {}
2481 Reaction::Capture => ctx.capture_pointer(),
2482 Reaction::Focus => ctx.request_focus(),
2483 Reaction::Layout => ctx.request_layout(),
2484 }
2485 ctx.dispatch_edit_command(EditCommand::Copy);
2486 ctx.request_redraw();
2487 }
2488 }
2489 EventResult::Handled
2490 }
2491 }
2492
2493 /// Drive the `()`-state host through a real root.
2494 fn unit_harness(reaction: Reaction) -> (RenderRoot<(), ToolbarHost>, Log) {
2495 let log: Log = Rc::new(RefCell::new(Vec::new()));
2496 let mut root: RenderRoot<(), ToolbarHost> = RenderRoot::new();
2497 let captured = Rc::clone(&log);
2498 let mut build = move |_: &mut ()| ToolbarHost {
2499 log: Rc::clone(&captured),
2500 reaction,
2501 };
2502 let mut state = ();
2503 root.rebuild(&mut build, &mut state);
2504 root.layout(WINDOW);
2505 root.paint(&mut RecordingScene::default(), FrameTime::from_nanos(0));
2506 (root, log)
2507 }
2508
2509 #[test]
2510 fn a_unit_typed_pod_is_hosted_and_its_edit_commands_reach_the_owner() {
2511 let (mut root, log) = unit_harness(Reaction::Nothing);
2512 let mut state = ();
2513 root.event(
2514 &mut state,
2515 &InputEvent::Pointer(PointerEvent {
2516 phase: PointerPhase::Down,
2517 position: Point::new(40.0, 50.0),
2518 button: PointerButton::Primary,
2519 }),
2520 );
2521 assert_eq!(
2522 log.borrow().clone(),
2523 vec!["pod:Down@32,10".to_string(), "cmd:Copy".to_string()],
2524 "the surface ran over its own `()` state and its command was drained"
2525 );
2526 }
2527
2528 #[test]
2529 fn a_focus_claim_from_a_substituted_pod_still_opens_a_session() {
2530 let (mut root, _log) = unit_harness(Reaction::Focus);
2531 let mut state = ();
2532 assert!(!root.is_focus_active());
2533 root.event(
2534 &mut state,
2535 &InputEvent::Pointer(PointerEvent {
2536 phase: PointerPhase::Down,
2537 position: Point::new(40.0, 50.0),
2538 button: PointerButton::Primary,
2539 }),
2540 );
2541 assert!(
2542 root.is_focus_active(),
2543 "the substituted context's focus claim is mirrored onto the owner"
2544 );
2545 }
2546
2547 #[test]
2548 fn a_layout_request_from_a_substituted_pod_reaches_the_pending_flags() {
2549 let (mut root, _log) = unit_harness(Reaction::Layout);
2550 root.take_change_flags();
2551 unit_pointer(&mut root, PointerPhase::Down, 40.0, 50.0);
2552 assert!(
2553 root.take_change_flags().contains(ChangeFlags::LAYOUT),
2554 "the substituted context's layout request is mirrored onto the owner, \
2555 so a shell gating layout on the pending flags runs the pass"
2556 );
2557 }
2558
2559 /// One pointer event into the `()`-state host, which carries no application
2560 /// state to thread.
2561 fn unit_pointer(root: &mut RenderRoot<(), ToolbarHost>, phase: PointerPhase, x: f64, y: f64) {
2562 let mut state = ();
2563 root.event(
2564 &mut state,
2565 &InputEvent::Pointer(PointerEvent {
2566 phase,
2567 position: Point::new(x, y),
2568 button: PointerButton::Primary,
2569 }),
2570 );
2571 }
2572
2573 #[test]
2574 fn a_second_gesture_into_a_substituted_pod_routes_exactly_like_the_first() {
2575 let (mut root, log) = unit_harness(Reaction::Capture);
2576 // The substituted route has no second road to the owner: the mirror in
2577 // `forward` is the only thing that can open the root's gesture, so a
2578 // capture it fails to report strands the follow-ups entirely.
2579 for label in ["first gesture", "second gesture"] {
2580 log.borrow_mut().clear();
2581 unit_pointer(&mut root, PointerPhase::Down, 40.0, 50.0);
2582 assert!(
2583 root.is_pointer_captured(),
2584 "{label}: the substituted pod's capture was mirrored onto the owner"
2585 );
2586 unit_pointer(&mut root, PointerPhase::Move, 300.0, 300.0);
2587 unit_pointer(&mut root, PointerPhase::Up, 300.0, 300.0);
2588 assert_eq!(
2589 log.borrow().clone(),
2590 vec![
2591 "pod:Down@32,10".to_string(),
2592 "cmd:Copy".to_string(),
2593 "pod:Move@292,260".to_string(),
2594 "pod:Up@292,260".to_string(),
2595 ],
2596 "{label}: every phase of the gesture reached the surface"
2597 );
2598 assert!(
2599 !root.is_pointer_captured(),
2600 "{label}: the Up released the capture"
2601 );
2602 }
2603 }
2604
2605 #[test]
2606 fn a_slot_starts_closed_and_hands_its_outside_press_over_once() {
2607 let mut slot: OverlaySlot<()> = OverlaySlot::new();
2608 assert!(!slot.is_open());
2609 assert_eq!(slot.window_rect(), Rect::ZERO);
2610 assert!(!slot.take_outside_down());
2611 // Two slots never share an identity, which is the whole addressing rule.
2612 let other: OverlaySlot<()> = OverlaySlot::default();
2613 assert_ne!(slot.key(), other.key());
2614 }
2615
2616 #[test]
2617 fn a_shortcut_quoting_the_slots_key_reaches_the_pod_as_a_plain_shortcut() {
2618 let mut slot: OverlaySlot<Vec<u32>> = OverlaySlot::new();
2619 let mut counter = 0u64;
2620 let view = any(crate::test_support::probe(7));
2621 slot.rebuild(None, Some(&view), &mut BuildCtx::new(&mut counter));
2622 let mut log: Vec<u32> = Vec::new();
2623 let mut ctx = EventCtx::new(&mut log, Point::ZERO, Size::new(100.0, 100.0));
2624 let id = frust_core::ShortcutId::next();
2625 let bare = InputEvent::Shortcut(id);
2626 assert_eq!(
2627 slot.event_ambient(&mut ctx, &bare),
2628 None,
2629 "never a bare broadcast"
2630 );
2631 let routed = InputEvent::Overlay(frust_core::OverlayEvent {
2632 key: slot.key(),
2633 kind: OverlayEventKind::Shortcut(id),
2634 });
2635 assert_eq!(
2636 slot.event_ambient(&mut ctx, &routed),
2637 Some(EventResult::Ignored)
2638 );
2639 assert_eq!(log, vec![7], "the pod heard it");
2640 }
2641
2642 // -----------------------------------------------------------------------
2643 // `OverlayAnchor::Window`: a pointer-following, absolute anchor
2644 // -----------------------------------------------------------------------
2645
2646 /// A `()`-typed pod that paints a rect at its own absolute origin, so a
2647 /// test can read the registered entry's placed rect back off the painted
2648 /// scene rather than reaching into state no owner exposes.
2649 struct GhostPod;
2650
2651 struct GhostPodWidget;
2652
2653 impl View<()> for GhostPod {
2654 type Element = GhostPodWidget;
2655 fn build(&self, _ctx: &mut BuildCtx<'_>) -> GhostPodWidget {
2656 GhostPodWidget
2657 }
2658 fn rebuild(
2659 &self,
2660 _prev: &Self,
2661 _element: &mut GhostPodWidget,
2662 _ctx: &mut BuildCtx<'_>,
2663 ) -> ChangeFlags {
2664 ChangeFlags::NONE
2665 }
2666 }
2667
2668 impl Widget for GhostPodWidget {
2669 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2670 bc.constrain(Size::new(80.0, 30.0))
2671 }
2672 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2673 scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
2674 }
2675 }
2676
2677 /// A `()`-typed probe with a configurable tag, filling whatever room it is
2678 /// offered — the "widget beneath" a `Transparent` surface must still
2679 /// reach, wherever that surface is anchored.
2680 struct TaggedProbe {
2681 tag: &'static str,
2682 log: Log,
2683 }
2684
2685 struct TaggedProbeWidget {
2686 tag: &'static str,
2687 log: Log,
2688 }
2689
2690 impl View<()> for TaggedProbe {
2691 type Element = TaggedProbeWidget;
2692 fn build(&self, _ctx: &mut BuildCtx<'_>) -> TaggedProbeWidget {
2693 TaggedProbeWidget {
2694 tag: self.tag,
2695 log: Rc::clone(&self.log),
2696 }
2697 }
2698 fn rebuild(
2699 &self,
2700 _prev: &Self,
2701 element: &mut TaggedProbeWidget,
2702 _ctx: &mut BuildCtx<'_>,
2703 ) -> ChangeFlags {
2704 element.log = Rc::clone(&self.log);
2705 ChangeFlags::NONE
2706 }
2707 }
2708
2709 impl Widget for TaggedProbeWidget {
2710 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2711 bc.constrain(bc.max())
2712 }
2713 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
2714 fn event(&mut self, _ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2715 if let InputEvent::Pointer(p) = event {
2716 self.log.borrow_mut().push(format!(
2717 "{}:{:?}@{},{}",
2718 self.tag, p.phase, p.position.x, p.position.y
2719 ));
2720 EventResult::Handled
2721 } else {
2722 EventResult::Ignored
2723 }
2724 }
2725 }
2726
2727 /// An owner that fills the window and, on every pointer `Move`/`Down`,
2728 /// moves its own `Window`-anchored, `Transparent`, `Tooltip`-band surface
2729 /// to the event's position — the shape a full-window pointer-tracking
2730 /// layer takes while a drag ghost is live. Never handles the event itself,
2731 /// so whatever sits beneath it in the main tree still sees every press.
2732 struct GhostOwner {
2733 offset: Vec2,
2734 }
2735
2736 struct GhostOwnerWidget {
2737 slot: OverlaySlot<()>,
2738 }
2739
2740 impl View<()> for GhostOwner {
2741 type Element = GhostOwnerWidget;
2742 fn build(&self, ctx: &mut BuildCtx<'_>) -> GhostOwnerWidget {
2743 let mut slot = OverlaySlot::new();
2744 slot.set_band(OverlayBand::Tooltip);
2745 slot.set_input(OverlayInput::Transparent);
2746 slot.set_anchor(OverlayAnchor::Window {
2747 point: Point::ZERO,
2748 offset: self.offset,
2749 });
2750 let view: AnyView<()> = any(GhostPod);
2751 slot.rebuild(None, Some(&view), ctx);
2752 GhostOwnerWidget { slot }
2753 }
2754 fn rebuild(
2755 &self,
2756 _prev: &Self,
2757 _element: &mut GhostOwnerWidget,
2758 _ctx: &mut BuildCtx<'_>,
2759 ) -> ChangeFlags {
2760 ChangeFlags::NONE
2761 }
2762 }
2763
2764 impl Widget for GhostOwnerWidget {
2765 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2766 self.slot.layout(ctx);
2767 bc.constrain(bc.max())
2768 }
2769 fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
2770 let size = ctx.size();
2771 self.slot.paint(ctx, size);
2772 }
2773 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2774 if let InputEvent::Pointer(p) = event
2775 && matches!(p.phase, PointerPhase::Move | PointerPhase::Down)
2776 {
2777 self.slot.set_window_anchor(ctx, p.position);
2778 }
2779 EventResult::Ignored
2780 }
2781 }
2782
2783 /// A plain `Floating`, `Owner`-anchored, `Interactive` surface — the
2784 /// counterpart `a_window_anchored_tooltip_still_paints_above_a_floating_surface`
2785 /// checks the band sort against.
2786 struct FloatingOwner;
2787
2788 struct FloatingOwnerWidget {
2789 slot: OverlaySlot<()>,
2790 }
2791
2792 impl View<()> for FloatingOwner {
2793 type Element = FloatingOwnerWidget;
2794 fn build(&self, ctx: &mut BuildCtx<'_>) -> FloatingOwnerWidget {
2795 let mut slot = OverlaySlot::new();
2796 slot.set_placement(corner());
2797 let view: AnyView<()> = any(GhostPod);
2798 slot.rebuild(None, Some(&view), ctx);
2799 FloatingOwnerWidget { slot }
2800 }
2801 fn rebuild(
2802 &self,
2803 _prev: &Self,
2804 _element: &mut FloatingOwnerWidget,
2805 _ctx: &mut BuildCtx<'_>,
2806 ) -> ChangeFlags {
2807 ChangeFlags::NONE
2808 }
2809 }
2810
2811 impl Widget for FloatingOwnerWidget {
2812 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2813 self.slot.layout(ctx);
2814 bc.constrain(Size::new(100.0, 40.0))
2815 }
2816 fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
2817 let size = ctx.size();
2818 self.slot.paint(ctx, size);
2819 }
2820 }
2821
2822 /// Drive a window-filling [`GhostOwner`] over a [`TaggedProbe`] floor
2823 /// through a real root.
2824 fn ghost_harness(offset: Vec2) -> (RenderRoot<(), StackView<()>>, Log) {
2825 let beneath_log: Log = Rc::new(RefCell::new(Vec::new()));
2826 let mut root: RenderRoot<(), StackView<()>> = RenderRoot::new();
2827 let log = Rc::clone(&beneath_log);
2828 let mut build = move |_: &mut ()| {
2829 stack()
2830 .child(TaggedProbe {
2831 tag: "beneath",
2832 log: Rc::clone(&log),
2833 })
2834 .child(GhostOwner { offset })
2835 };
2836 let mut state = ();
2837 root.rebuild(&mut build, &mut state);
2838 root.layout(WINDOW);
2839 root.paint(&mut RecordingScene::default(), FrameTime::from_nanos(0));
2840 (root, beneath_log)
2841 }
2842
2843 #[test]
2844 fn a_window_anchor_paints_at_point_plus_offset() {
2845 let offset = Vec2::new(5.0, 7.0);
2846 let (mut root, _log) = ghost_harness(offset);
2847 let mut scene = RecordingScene::default();
2848 root.paint(&mut scene, FrameTime::from_nanos(16_000_000));
2849 assert_eq!(
2850 rects(&scene).last().copied(),
2851 Some(Rect::from_origin_size(
2852 Point::ZERO + offset,
2853 Size::new(80.0, 30.0)
2854 )),
2855 "a Window anchor's rect is point + offset, with no side/align geometry"
2856 );
2857 }
2858
2859 #[test]
2860 fn moving_a_window_anchor_moves_the_paint_rect_on_the_next_paint() {
2861 let offset = Vec2::new(5.0, 7.0);
2862 let (mut root, _log) = ghost_harness(offset);
2863 let mut state = ();
2864 root.event(
2865 &mut state,
2866 &InputEvent::Pointer(PointerEvent {
2867 phase: PointerPhase::Down,
2868 position: Point::new(100.0, 200.0),
2869 button: PointerButton::Primary,
2870 }),
2871 );
2872 let mut scene = RecordingScene::default();
2873 root.paint(&mut scene, FrameTime::from_nanos(16_000_000));
2874 assert_eq!(
2875 rects(&scene).last().copied(),
2876 Some(Rect::from_origin_size(
2877 Point::new(100.0, 200.0) + offset,
2878 Size::new(80.0, 30.0)
2879 )),
2880 "the event handler's set_window_anchor call moved the surface to \
2881 the pointer, picked up on the following paint"
2882 );
2883
2884 root.event(
2885 &mut state,
2886 &InputEvent::Pointer(PointerEvent {
2887 phase: PointerPhase::Move,
2888 position: Point::new(250.0, 40.0),
2889 button: PointerButton::Primary,
2890 }),
2891 );
2892 let mut scene = RecordingScene::default();
2893 root.paint(&mut scene, FrameTime::from_nanos(32_000_000));
2894 assert_eq!(
2895 rects(&scene).last().copied(),
2896 Some(Rect::from_origin_size(
2897 Point::new(250.0, 40.0) + offset,
2898 Size::new(80.0, 30.0)
2899 )),
2900 "a further Move keeps tracking the pointer"
2901 );
2902 }
2903
2904 #[test]
2905 fn a_window_anchored_transparent_surface_still_lets_a_press_through() {
2906 let (mut root, log) = ghost_harness(Vec2::new(5.0, 7.0));
2907 let mut state = ();
2908 root.event(
2909 &mut state,
2910 &InputEvent::Pointer(PointerEvent {
2911 phase: PointerPhase::Down,
2912 position: Point::new(100.0, 200.0),
2913 button: PointerButton::Primary,
2914 }),
2915 );
2916 assert_eq!(
2917 log.borrow().clone(),
2918 vec!["beneath:Down@100,200".to_string()],
2919 "`Transparent` never hit-tests, Window anchor or not — the press \
2920 reaches the widget the ghost is floating over"
2921 );
2922 }
2923
2924 #[test]
2925 fn a_window_anchored_tooltip_still_paints_above_a_floating_surface() {
2926 let offset = Vec2::new(5.0, 7.0);
2927 let mut root: RenderRoot<(), StackView<()>> = RenderRoot::new();
2928 // Registered Tooltip-first (`GhostOwner`), Floating-second
2929 // (`FloatingOwner`): exactly the order the band sort has to survive,
2930 // not merely happen to preserve.
2931 let mut build = move |_: &mut ()| stack().child(GhostOwner { offset }).child(FloatingOwner);
2932 let mut state = ();
2933 root.rebuild(&mut build, &mut state);
2934 root.layout(WINDOW);
2935 let mut scene = RecordingScene::default();
2936 root.paint(&mut scene, FrameTime::from_nanos(0));
2937 assert_eq!(
2938 rects(&scene),
2939 vec![
2940 Rect::new(8.0, 40.0, 88.0, 70.0),
2941 Rect::from_origin_size(Point::ZERO + offset, Size::new(80.0, 30.0)),
2942 ],
2943 "Floating still paints below Tooltip despite registering after it: \
2944 {:?}",
2945 rects(&scene)
2946 );
2947 }
2948
2949 // -----------------------------------------------------------------------
2950 // Tab traversal into a floated pod
2951 // -----------------------------------------------------------------------
2952
2953 /// A focusable leaf that records, on each paint, whether it holds focus.
2954 struct Stop {
2955 focused: Rc<std::cell::Cell<bool>>,
2956 }
2957
2958 struct StopWidget {
2959 focused: Rc<std::cell::Cell<bool>>,
2960 }
2961
2962 impl View<TabApp> for Stop {
2963 type Element = StopWidget;
2964 fn build(&self, _ctx: &mut BuildCtx<'_>) -> StopWidget {
2965 StopWidget {
2966 focused: Rc::clone(&self.focused),
2967 }
2968 }
2969 fn rebuild(
2970 &self,
2971 _prev: &Self,
2972 element: &mut StopWidget,
2973 _ctx: &mut BuildCtx<'_>,
2974 ) -> ChangeFlags {
2975 element.focused = Rc::clone(&self.focused);
2976 ChangeFlags::NONE
2977 }
2978 }
2979
2980 impl Widget for StopWidget {
2981 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2982 bc.constrain(Size::new(40.0, 20.0))
2983 }
2984 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2985 self.focused.set(ctx.has_focus());
2986 scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
2987 }
2988 fn event(&mut self, _ctx: &mut EventCtx, _event: &InputEvent) -> EventResult {
2989 EventResult::Ignored
2990 }
2991 fn is_focusable(&self) -> bool {
2992 true
2993 }
2994 }
2995
2996 /// The owner's button and the floated pod's content, and whether the pod is mounted.
2997 #[derive(Default)]
2998 struct TabApp {
2999 open: bool,
3000 owner: Rc<std::cell::Cell<bool>>,
3001 pod: Rc<std::cell::Cell<bool>>,
3002 }
3003
3004 fn tab_logic(state: &mut TabApp) -> StackView<TabApp> {
3005 let surface = state.open.then(|| Stop {
3006 focused: Rc::clone(&state.pod),
3007 });
3008 stack().child(
3009 overlay_portal(Stop {
3010 focused: Rc::clone(&state.owner),
3011 })
3012 .overlay(surface),
3013 )
3014 }
3015
3016 fn tab_frame(root: &mut RenderRoot<TabApp, StackView<TabApp>>, state: &mut TabApp) {
3017 let mut build: fn(&mut TabApp) -> StackView<TabApp> = tab_logic;
3018 root.rebuild(&mut build, state);
3019 root.layout(WINDOW);
3020 root.paint(&mut RecordingScene::default(), FrameTime::from_nanos(0));
3021 }
3022
3023 /// One Tab (or Shift+Tab) press, then a frame so the paint reports the new focus.
3024 fn tab_press(
3025 root: &mut RenderRoot<TabApp, StackView<TabApp>>,
3026 state: &mut TabApp,
3027 shift: bool,
3028 ) {
3029 let event = KeyEvent {
3030 key: Key::Named(NamedKey::Tab),
3031 modifiers: Modifiers {
3032 shift,
3033 ..Modifiers::default()
3034 },
3035 repeat: false,
3036 };
3037 root.event(state, &InputEvent::Key(event));
3038 tab_frame(root, state);
3039 }
3040
3041 #[test]
3042 fn tab_moves_from_the_owner_into_an_open_pass_pod_and_shift_tab_comes_back() {
3043 let mut root = RenderRoot::new();
3044 let mut state = TabApp {
3045 open: true,
3046 ..TabApp::default()
3047 };
3048 tab_frame(&mut root, &mut state);
3049
3050 tab_press(&mut root, &mut state, false);
3051 assert!(state.owner.get(), "the owner's button takes the first stop");
3052 assert!(!state.pod.get());
3053
3054 tab_press(&mut root, &mut state, false);
3055 assert!(root.is_focus_active());
3056 assert!(state.pod.get(), "the pod's content takes the next stop");
3057 assert!(!state.owner.get(), "the owner gives up focus to the pod");
3058
3059 tab_press(&mut root, &mut state, true);
3060 assert!(state.owner.get(), "Shift+Tab goes back to the owner's stop");
3061 assert!(!state.pod.get());
3062 }
3063
3064 #[test]
3065 fn a_closed_pod_contributes_no_stops() {
3066 let mut root = RenderRoot::new();
3067 let mut state = TabApp::default();
3068 tab_frame(&mut root, &mut state);
3069
3070 tab_press(&mut root, &mut state, false);
3071 assert!(state.owner.get());
3072 // The owner is the only stop, so Tab wraps back onto it and the pod
3073 // never takes focus.
3074 tab_press(&mut root, &mut state, false);
3075 assert!(state.owner.get());
3076 assert!(!state.pod.get(), "an unmounted pod is never a stop");
3077 }
3078}