frust_widgets/authoring.rs
1//! The widget-authoring toolkit: the shared child plumbing, event routing and
2//! callback erasure every container/interactive widget is assembled from — the
3//! surface for authoring widgets, and whole design systems, **outside this
4//! crate**.
5//!
6//! A container (a `View`/`Widget` pair owning children) or an interactive leaf
7//! (one reporting a press back into application state) needs three things
8//! `frust-core` deliberately does not provide:
9//!
10//! - **Child plumbing**: [`build_child`]/[`rebuild_child`]/[`teardown_child`] for
11//! a single [`AnyView`] child, and [`rebuild_children`] for a `Vec` of them
12//! (positional or [`ChildKey`]-keyed reconciliation, with the focus/capture
13//! retention rules a hand-rolled diff gets wrong).
14//! - **Event routing**: [`route_event`] (multi-child containers, reverse paint
15//! order + capture/focus fast paths) and [`route_event_single`] (one-child
16//! wrappers).
17//! - **Callback erasure**: [`erase_callback`]/[`erase_callback_arg`], turning a
18//! view-held `Rc<dyn Fn(&mut State)>` into the [`ErasedCallback`]/
19//! [`ErasedArgCallback`] adapter a non-generic widget holds, so the retained
20//! widget never becomes generic over the application-state type.
21//! - **Child visitation**: [`visit_children!`] over [`VisitPods`], writing a
22//! container's [`Widget::visit_children`] body from its child fields — the
23//! read-only seam that lets tooling walk into a container's retained subtree
24//! (`frust-core`'s `WidgetTree::inspect`). One line per container; a leaf
25//! needs nothing.
26//! - **Themed text roles**: [`ThemeTextColor`] (re-exported here) and
27//! [`TextView::themed_role`](crate::TextView::themed_role) — how a widget labels
28//! a child [`text`](crate::text) run with the themed color role it should
29//! default to, instead of hardcoding a color; and [`ThemeTextType`]
30//! (re-exported here) with
31//! [`TextView::themed_family`](crate::TextView::themed_family) — how it opts
32//! the run's font family into the active theme's type-scale role, resolved
33//! at layout so a live theme swap re-shapes it, instead of shaping in
34//! `TextStyle::default()`'s system UI font.
35//!
36//! # Hover: claim it, latch it, then correct it at paint time
37//!
38//! There is no hover phase and no Enter/Leave event. A widget that wants hover
39//! chrome opts in with three things, each covering a case the others cannot:
40//!
41//! - In its **uncaptured** [`PointerPhase::Move`] arm, hit-test the event position
42//! against its own bounds and call
43//! [`EventCtx::claim_hover`](frust_core::EventCtx::claim_hover) when it is
44//! inside — on *every* such move, not just on entry (the claim is per-pass, not
45//! sticky).
46//! - Latch that same hit test into an internal hover flag, and gate
47//! `request_redraw` on the flag actually *changing*. This is the frame source
48//! for hover gain (and for the link moving from a sibling onto this widget):
49//! `claim_hover` asks for no redraw, and the pipeline manufactures one only when
50//! a hover ends with nothing taking it. Gating on the change is also what keeps
51//! a pointer wandering inside one widget from repainting per event.
52//! - In `paint`, read
53//! [`PaintCtx::is_hovered`](frust_core::PaintCtx::is_hovered) as the
54//! authoritative value and self-correct the flag from it. This is not optional
55//! belt-and-braces: a pointer leaving the widget routes its next move onto
56//! whatever it moved *onto*, so the widget it left never gets an event saying so.
57//!
58//! Everything else is the pipeline's job. `frust-core` stamps the claim down the
59//! pod chain and strands the previous claimant by advancing a hover epoch, so a
60//! container needs no hover bookkeeping at all — [`route_event`] and
61//! [`route_event_single`] carry it for free, and a hand-rolled router that
62//! forwards through [`ChildPod::event_child`] does too. Because the claim is
63//! recorded as a *path*, an enclosing container reads hovered while the pointer is
64//! over a claiming child (CSS `:hover` semantics), and every off-path widget reads
65//! `false`.
66//!
67//! A container that wants hover chrome of its **own** has one extra rule: it
68//! claims *after* routing the move to its children, never before. Only one claim
69//! per pass is recorded and the first one recorded wins, so an ancestor that claims
70//! before it forwards makes every descendant ineligible for the pass — the child
71//! under the pointer never reads hovered, while its latched flag keeps flipping and
72//! asking for a frame on every move. Claiming after routing makes the container's
73//! claim a fallback: a child's claim wins and the container still reads hovered
74//! through the path, and when no child claims the container's own claim is what
75//! records.
76//!
77//! A **captured** pointer can never create hover, so a drag never paints hover
78//! under the finger and a widget that captures its own gesture is hover-free for
79//! the length of it. Nothing distinguishes a touch contact from a mouse, though: a
80//! touch drag that captured nothing is an ordinary hover pass, so a non-capturing
81//! consumer can tint transiently under a finger — the `Up` at lift ends the link
82//! (see `docs/LIMITATIONS.md`'s `hover-window-leave-standing`).
83//!
84//! **Application code should prefer `frust::authoring`**, which re-exports
85//! everything below plus the `frust-core` trait vocabulary and `kurbo`/`peniko`
86//! geometry — so an app depends on `frust` alone. The example spells its imports
87//! the long way only because this crate cannot name `frust` without a
88//! dev-dependency cycle; `frust::authoring`'s own module docs carry the
89//! facade-spelled version.
90//!
91//! **Stability: pre-1.0, and a real supported public API** — not `#[doc(hidden)]`
92//! plumbing, so a change to any signature here is a **breaking change**, released
93//! as such. The three catalogs shipping with this crate (`material`, `cupertino`,
94//! `glyph`) consume exactly this surface and nothing more, so a third-party design
95//! system authored against it is at parity with the built-ins by construction.
96//!
97//! # Example: a one-child container widget
98//!
99//! A container that offsets its single child, wired through the full lifecycle —
100//! build, rebuild, teardown, layout, paint, event routing, semantics forwarding:
101//!
102//! ```
103//! use frust_core::{
104//! AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult,
105//! InputEvent, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, Widget, any,
106//! };
107//! use frust_widgets::authoring::{build_child, rebuild_child, route_event_single, teardown_child};
108//! use kurbo::{Point, Size};
109//!
110//! /// The declarative half: a child plus the offset to apply to it.
111//! struct OffsetView<State: 'static> {
112//! offset: Point,
113//! child: AnyView<State>,
114//! }
115//!
116//! /// The view-fn app code calls; `any` erases the concrete child view.
117//! fn offset<State: 'static, V: View<State>>(offset: Point, child: V) -> OffsetView<State> {
118//! OffsetView { offset, child: any(child) }
119//! }
120//!
121//! /// The retained half: the live child pod plus the applied offset.
122//! struct OffsetWidget {
123//! offset: Point,
124//! child: ChildPod,
125//! }
126//!
127//! impl<State: 'static> View<State> for OffsetView<State> {
128//! type Element = OffsetWidget;
129//!
130//! fn build(&self, ctx: &mut BuildCtx<'_>) -> OffsetWidget {
131//! OffsetWidget { offset: self.offset, child: build_child(&self.child, ctx) }
132//! }
133//!
134//! fn rebuild(
135//! &self,
136//! prev: &Self,
137//! element: &mut OffsetWidget,
138//! ctx: &mut BuildCtx<'_>,
139//! ) -> ChangeFlags {
140//! let mut flags = ChangeFlags::NONE;
141//! if prev.offset != self.offset {
142//! element.offset = self.offset;
143//! flags |= ChangeFlags::LAYOUT;
144//! }
145//! flags | rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
146//! }
147//!
148//! fn teardown(&self, element: &mut OffsetWidget, ctx: &mut BuildCtx<'_>) {
149//! teardown_child(&self.child, &mut element.child, ctx);
150//! }
151//! }
152//!
153//! impl Widget for OffsetWidget {
154//! fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
155//! let size = self.child.layout_child(ctx, bc);
156//! self.child.set_origin(self.offset);
157//! bc.constrain(size)
158//! }
159//!
160//! fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
161//! self.child.paint_child(ctx, scene);
162//! }
163//!
164//! fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
165//! // Never re-hit-test a captured child by hand — this helper owns the
166//! // capture/focus fast paths and the blur-on-outside-tap rule.
167//! route_event_single(&mut self.child, ctx, event)
168//! }
169//!
170//! fn semantics(&self, ctx: &mut SemanticsCtx) {
171//! // A transparent wrapper still MUST forward, or the child's whole
172//! // subtree drops out of the accessibility tree.
173//! self.child.semantics_child(ctx);
174//! }
175//!
176//! // The read-only tooling seam: publish every pod this widget owns.
177//! frust_widgets::authoring::visit_children!(child);
178//! }
179//! # fn main() {}
180//! ```
181
182use std::any::Any;
183use std::collections::{HashMap, HashSet};
184use std::rc::Rc;
185
186use frust_core::{
187 AnyView, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, PointerButton, PointerEvent,
188 PointerPhase, View, Widget,
189};
190use kurbo::Point;
191
192use crate::ChildKey;
193
194pub use crate::image::ImageSource;
195pub use crate::text::{ThemeTextColor, ThemeTextType};
196
197/// The overlay-portal authoring surface: the slot a widget that floats its own
198/// surface holds, what that surface is anchored to, and the placement geometry
199/// every anchored pattern needs.
200///
201/// Promoted here rather than left to each design system because all three of
202/// them had derived the same geometry independently: a design system's popover,
203/// menu, tooltip and context menu place a surface through [`place`] and host it
204/// through [`OverlaySlot`], reaching both through `frust::authoring` alone.
205pub use crate::overlay::{
206 OverlayAlign, OverlayAnchor, OverlayPlacement, OverlaySide, OverlaySlot, place,
207};
208
209/// The overlay registry's own vocabulary, re-exported from `frust-core` so a
210/// design system naming a band, an input class, a light-dismiss policy or a
211/// surface identity does it through this one module: an
212/// [`OverlaySlot`]'s setters take exactly these types, and a widget that cannot
213/// name them cannot configure one.
214pub use frust_core::{OutsideTap, OverlayBand, OverlayInput, OverlayKey};
215
216/// Pressed-state overlay opacity (source: androidx Compose Material3
217/// `StateTokens` v0_210, retrieved 2026-07-17 — supersedes material-web
218/// v0.192's 12%).
219///
220/// Lives here, not with the rest of the M3 state-layer table, because it is the
221/// one interaction opacity a *language-neutral* widget needs: a press overlay is
222/// a mechanism (how a control acknowledges a touch), not a Material design
223/// decision. A design system's own state-layer table is free to differ; this is
224/// the value the baseline widgets and the built-in catalogs share.
225pub const PRESSED_OPACITY: f32 = 0.10;
226
227/// A widget-held, `State`-erased app-state callback adapter (see
228/// [`erase_callback`]).
229pub type ErasedCallback = Box<dyn FnMut(&mut EventCtx)>;
230
231/// A widget-held, `State`-erased callback adapter carrying one value argument
232/// (see [`erase_callback_arg`]).
233pub type ErasedArgCallback<A> = Box<dyn FnMut(&mut EventCtx, A)>;
234
235/// A view-held, typed callback carrying one value argument (Checkbox's `bool`,
236/// Slider's `f64`), erased to [`ErasedArgCallback`] on build.
237pub type TypedArgCallback<State, A> = std::rc::Rc<dyn Fn(&mut State, A)>;
238
239/// Erase a view-held `Rc<dyn Fn(&mut State)>` app-state callback into the
240/// widget-held [`ErasedCallback`] adapter the interactive widgets invoke during
241/// the event pass.
242///
243/// The closure recovers the concrete `State` from the type-erased [`EventCtx`]
244/// with [`EventCtx::state_mut`] (the downcast happens *inside* the adapter), so
245/// the widget itself stays non-generic over `State`. Closures aren't comparable,
246/// so `build`/`rebuild` reinstall the adapter unconditionally — it's cheap.
247pub fn erase_callback<State: 'static>(callback: &Rc<dyn Fn(&mut State)>) -> ErasedCallback {
248 let callback = callback.clone();
249 Box::new(move |ctx: &mut EventCtx| {
250 let state = ctx.state_mut::<State>();
251 callback(state);
252 })
253}
254
255/// Like [`erase_callback`], but for callbacks that also carry a value argument
256/// (Checkbox's `bool`, Slider's `f64`).
257pub fn erase_callback_arg<State: 'static, A: 'static>(
258 callback: &TypedArgCallback<State, A>,
259) -> ErasedArgCallback<A> {
260 let callback = callback.clone();
261 Box::new(move |ctx: &mut EventCtx, arg: A| {
262 let state = ctx.state_mut::<State>();
263 callback(state, arg);
264 })
265}
266
267/// Build a [`ChildPod`] wrapping an [`AnyView`]'s element.
268///
269/// The element (`Box<dyn Widget>`) is stored double-boxed so a later
270/// [`rebuild_child`] can recover it as `&mut Box<dyn Widget>` — the type
271/// `AnyView`'s `rebuild` needs to swap the widget on a concrete-type change.
272///
273/// The build runs with the focus chain closed
274/// ([`BuildCtx::with_focus_link`]`(false, …)`): the pod being built is brand new,
275/// so by construction it holds no focus link — the same composition
276/// [`rebuild_child`] performs, kept uniform so "descending into a pod always
277/// recomputes the chain" has no exceptions.
278pub fn build_child<State: 'static>(view: &AnyView<State>, ctx: &mut BuildCtx<'_>) -> ChildPod {
279 let element: Box<dyn Widget> = ctx.with_focus_link(false, |ctx| view.build(ctx));
280 ChildPod::new(Box::new(element))
281}
282
283/// Reconcile one [`AnyView`] child in place through its `ChildPod`.
284///
285/// A type-swapped child (see `rebuild_child_tracked`) that still holds a
286/// recorded capture has that capture dropped: `pod.set_active(false)`, no
287/// synthetic `Cancel`. The old armed widget was torn down inside
288/// `AnyView::rebuild` — its state died with it — and the fresh widget in its
289/// place never saw the original `Down`, so there is nothing to unwind; this
290/// mirrors [`rebuild_children`]'s documented type-swap semantics for the
291/// single-child wrappers (`Padding`/`Align`/`SizedBox`, and the interactive
292/// widgets' own label/track children) that call this instead of
293/// `rebuild_children`.
294///
295/// **A type swap severs the recorded focus path exactly like it severs the
296/// capture path**, and is handled the same way the keyed reconciler handles its
297/// own swap arm: the pod's `focused` flag is dropped (the fresh widget never
298/// claimed focus — leaving the link set would route `Key`/`Ime` events into a
299/// widget that ignores them) and
300/// [`mark_focus_orphaned`](frust_core::mark_focus_orphaned) is raised *when the
301/// severed link was on the live focus chain* ([`mark_orphan_if_live`]), so
302/// `RenderRoot::rebuild` releases `focus_active`/`ime_state` before the frame
303/// ends. This is the `Padding(if editing { text_input } else { text })` shape:
304/// without the release the keyboard stays up over an idle screen and
305/// `is_focus_active()` keeps reporting a session whose owner no longer exists. A
306/// swap of a merely *stale* flag under an already-blurred ancestor marks
307/// nothing — it owned no session.
308pub fn rebuild_child<State: 'static>(
309 prev: &AnyView<State>,
310 next: &AnyView<State>,
311 pod: &mut ChildPod,
312 ctx: &mut BuildCtx<'_>,
313) -> ChangeFlags {
314 // Read before the nested rebuild, exactly as `rebuild_child_tracked` reads
315 // its own `link_focused`: the gate describes the link this pod held *going
316 // into* the swap.
317 let link_focused = pod.is_focused();
318 let (flags, swapped) = rebuild_child_tracked(prev, next, pod, ctx);
319 if swapped {
320 if pod.is_active() {
321 pod.set_active(false);
322 }
323 if link_focused {
324 pod.set_focused(false);
325 }
326 // `ctx` carries the chain down to *this wrapper*; the pod's own flag is
327 // ANDed onto it inside, the same composition every other marking site
328 // uses.
329 mark_orphan_if_live(ctx, link_focused);
330 }
331 flags
332}
333
334/// Like [`rebuild_child`], but also reports whether the rebuild *replaced* the
335/// underlying widget (an [`AnyView`] concrete-type swap) rather than mutating it
336/// in place.
337///
338/// The swap flag drives both callers' capture bookkeeping: [`rebuild_children`]'s
339/// (multi-child `Vec` containers — `Flex`/`Stack`) and [`rebuild_child`]'s
340/// (single-child wrappers). A fresh widget swapped in at a still-captured
341/// index/pod never saw the original `Down`, so its stale `active` path is
342/// dropped without a synthetic `Cancel` (there is nothing armed to unwind).
343/// Detection compares the boxed element's concrete
344/// [`TypeId`](std::any::TypeId) across the rebuild.
345///
346/// **This is the descent that extends the focus chain.** The nested rebuild runs
347/// under [`BuildCtx::with_focus_link`]`(pod.is_focused(), …)`, so a container
348/// deeper in the tree sees `ctx.has_focus()` == "is the whole chain from the root
349/// to me focused" — the value the orphan-marking sites gate on. Every route into
350/// a child's `rebuild`/`teardown` funnels through here (or through
351/// [`teardown_child`]), which is what makes the chain complete through arbitrary
352/// nesting.
353fn rebuild_child_tracked<State: 'static>(
354 prev: &AnyView<State>,
355 next: &AnyView<State>,
356 pod: &mut ChildPod,
357 ctx: &mut BuildCtx<'_>,
358) -> (ChangeFlags, bool) {
359 // Read before the `widget_mut` borrow below.
360 let link_focused = pod.is_focused();
361 let element = pod
362 .widget_mut()
363 .downcast_mut::<Box<dyn Widget>>()
364 .expect("layout-container child element is a boxed AnyView widget");
365 let before = {
366 let any: &dyn Any = &**element;
367 any.type_id()
368 };
369 let flags = ctx.with_focus_link(link_focused, |ctx| next.rebuild(prev, element, ctx));
370 let after = {
371 let any: &dyn Any = &**element;
372 any.type_id()
373 };
374 (flags, before != after)
375}
376
377/// Tear down one [`AnyView`] child through its `ChildPod`.
378///
379/// A pod still holding an in-flight capture ([`ChildPod::is_active`]) is
380/// cancelled (a synthetic `Cancel`, see `cancel_pod`) before teardown, so an
381/// armed widget dropped mid-gesture (e.g. the active row truncated out of a
382/// shrinking list) unwinds its state machine instead of vanishing with no
383/// terminating `Up`/`Cancel`.
384///
385/// A pod still holding the recorded **focus** path ([`ChildPod::is_focused`])
386/// *on the live focus chain* ([`BuildCtx::has_focus`]) raises
387/// [`mark_focus_orphaned`](frust_core::mark_focus_orphaned) instead: the focused
388/// widget is about to stop existing, so the whole focus/IME session dies with it
389/// and `RenderRoot::rebuild` releases it before the frame ends (see
390/// [`cancel_active_children`]'s note for the full mechanism, and why the pod
391/// cannot do it itself). A pod whose flag is stale — set, but under a link some
392/// ancestor already cleared — is torn down silently: it owns no session to lose.
393pub fn teardown_child<State: 'static>(
394 view: &AnyView<State>,
395 pod: &mut ChildPod,
396 ctx: &mut BuildCtx<'_>,
397) {
398 if pod.is_active() {
399 cancel_pod(pod);
400 pod.set_active(false);
401 }
402 let link_focused = pod.is_focused();
403 if link_focused {
404 // The pod is dropped by the caller right after this returns, so clearing
405 // the flag is bookkeeping hygiene, not the load-bearing part — the mark is.
406 pod.set_focused(false);
407 }
408 mark_orphan_if_live(ctx, link_focused);
409 if let Some(element) = pod.widget_mut().downcast_mut::<Box<dyn Widget>>() {
410 // Descend with the chain extended by the link this pod held *before* the
411 // clear above: a focused pod's subtree is still on the live chain while it
412 // is being torn down, so a focused descendant reports its own orphan.
413 ctx.with_focus_link(link_focused, |ctx| view.teardown(element, ctx));
414 }
415}
416
417/// Raise the orphan mark for a severed focus link, but **only when the link was
418/// live** — the one gate every marking site in this module shares
419/// ([`teardown_child`], [`cancel_active_children`], the keyed reconciler's
420/// type-swap arm, and [`rebuild_child`]'s).
421///
422/// `link_focused` is the dying pod's own [`ChildPod::is_focused`] flag and
423/// `ctx.has_focus()` is the chain above it, so the mark means exactly one thing:
424/// **a LIVE session just lost its owner.** Both halves are required. The flag
425/// alone is not evidence of a live session — a container-routed blur clears the
426/// focus link at the nearest common ancestor only, leaving flags deeper in the
427/// blurred branch legitimately set (see [`route_event`]) — and marking on the
428/// flag alone is what let a recycled list row, a filtered-out item, or a switched
429/// pattern release the *currently typed-into* field somewhere else in the tree.
430/// The chain alone is not evidence either: a live chain running past an unfocused
431/// sibling says nothing about that sibling.
432fn mark_orphan_if_live(ctx: &BuildCtx<'_>, link_focused: bool) {
433 if link_focused && ctx.has_focus() {
434 frust_core::mark_focus_orphaned();
435 }
436}
437
438/// Deliver a synthetic [`PointerPhase::Cancel`] to a captured child whose
439/// in-flight gesture a structural rebuild has invalidated, so its state machine
440/// unwinds instead of firing on a later `Up`.
441///
442/// # Cancel-during-rebuild contract
443///
444/// The rebuild pass runs over a [`BuildCtx`], not an [`EventCtx`] — there is no
445/// application state in scope. This is sound *only because a `Cancel` handler
446/// must never read application state* (`EventCtx::state_mut`): every
447/// interactive widget's `Cancel` arm only clears internal flags. That invariant
448/// lets this build a minimal [`EventCtx`] over a throwaway `()` state to drive
449/// the unwind; a `Cancel` handler that reached for real state would panic here
450/// on the `()` downcast — a deliberate tripwire, not a silent corruption.
451pub(crate) fn cancel_pod(pod: &mut ChildPod) {
452 let mut dummy_state = ();
453 let mut ctx = EventCtx::new(&mut dummy_state, pod.origin(), pod.size());
454 // Position is irrelevant to a `Cancel` (handlers never hit-test on it).
455 let cancel = InputEvent::Pointer(PointerEvent {
456 phase: PointerPhase::Cancel,
457 position: Point::ZERO,
458 button: PointerButton::Primary,
459 });
460 pod.event_child(&mut ctx, &cancel);
461}
462
463/// Cancel-and-clear the recorded interaction paths of the pods handed to it —
464/// both the capture (`active`) path and the focus (`focused`) path — after a
465/// structural change.
466///
467/// Called by [`rebuild_children_positional`] on the **tail past the stable
468/// prefix** (indices `≥ k`, whose widget identity may have changed): a swapped
469/// slot, a shifted pod, or the grown tail. The stable prefix (indices `< k`)
470/// keeps the same live widget across the rebuild, so its recorded paths stay
471/// valid and are *not* passed here — that is Flutter's focus/IME retention
472/// invariant (see [`rebuild_children_positional`]). The keyed reconciler does not
473/// call this at all: a key-matched pod relocates with its flags intact, and only
474/// a torn-down or type-swapped pod (identity broken) is cleared inline there.
475///
476/// For each pod handed in, any surviving armed widget is unwound via [`cancel_pod`]
477/// and its `active` flag dropped, and any focused child has its `focused` flag
478/// dropped. `ctx` carries the focus chain down to the *container*
479/// ([`BuildCtx::has_focus`]), which each pod's own flag is ANDed onto to decide
480/// whether the clear severed a live session (see [`mark_orphan_if_live`]).
481///
482/// # Focus vs. capture: why one synthesizes a `Cancel` and the other does not
483///
484/// Capture state lives *inside* the widget (an `armed`/`pressed` flag its own
485/// event arms), so dropping the recorded path requires a synthetic [`Cancel`]
486/// ([`cancel_pod`]) to unwind that internal machine — otherwise it would fire on
487/// a later hit-tested `Up`. Focus state, by contrast, is *reflected* from the pod
488/// flag into the widget each event (`EventCtx::has_focus`, threaded through
489/// [`ChildPod::event_child`]) rather than latched internally, so clearing the pod
490/// flag is enough — there is no widget-internal blur to drive, and a `Cancel`
491/// handler must not touch app state anyway (`docs/CODE_STANDARDS.md`).
492///
493/// # RenderRoot notification: the orphan mark
494///
495/// Clearing `focused` here cannot notify [`RenderRoot`](frust_core::RenderRoot)
496/// directly — the rebuild pass runs over a [`BuildCtx`], with no `RenderRoot` in
497/// scope, exactly as the capture-cancel path above cannot reset
498/// `RenderRoot::pointer_captured`. It instead raises the thread-local
499/// [`mark_focus_orphaned`](frust_core::mark_focus_orphaned) flag, which
500/// `RenderRoot::rebuild` drains at the end of the same rebuild and turns into a
501/// full focus/IME session release (`focus_active` cleared, the shell-facing
502/// surface dropped, one edge on the focus/IME generation).
503///
504/// **Why the mark and not convergence.** Leaving the root's
505/// `focus_active`/`ime_state` stale for the next event pass to self-correct is
506/// fine while the user keeps touching the screen and wrong the moment they
507/// stop: on an idle screen no next event arrives, so the root
508/// keeps reporting a live focus session for a widget that no longer exists —
509/// which strands `ime_state()` at the shell and (measured on a Xiaomi 12) held
510/// the mobile frame gate's focus input up permanently after a navigator pop.
511/// A session must die with its owner, not with the next tap.
512///
513/// **Why a thread-local and not a tree walk.** Validating the root's mirror
514/// against reality after the diff would need to ask "does any pod in the tree
515/// still hold focus?", and there is no such iterator: `Widget` exposes no
516/// children, so nothing can walk the retained tree generically. The mark is the
517/// only channel available from inside a `BuildCtx` pass, and it mirrors
518/// `mark_pending_result_flush`'s shape exactly (data-free, idempotent,
519/// UI-thread-affine).
520///
521/// **A stale flag deep in a blurred branch is harmless by construction.** A
522/// container-routed blur clears the focus link at the nearest common ancestor
523/// only, so `focused` flags *below* that link stay set until focus next enters
524/// the subtree (see [`route_event`]). Such a pod is not evidence of a session:
525/// the mark is gated on the whole chain ([`mark_orphan_if_live`]), which a
526/// cleared ancestor link forces to `false` for the entire subtree below it —
527/// exactly as the same composition already hides those flags from paint
528/// (`ChildPod::paint_child`'s `self.focused && ctx.has_focus()`) and as
529/// focus-path routing already refuses to deliver into them. Tearing down a stale
530/// branch — a recycled list row, a filtered-out item, a switched pattern —
531/// therefore leaves the session of whatever field is *actually* focused
532/// untouched, instead of dropping the keyboard mid-typing somewhere unrelated.
533fn cancel_active_children(pods: &mut [ChildPod], ctx: &BuildCtx<'_>) {
534 for pod in pods.iter_mut() {
535 if pod.is_active() {
536 cancel_pod(pod);
537 pod.set_active(false);
538 }
539 let link_focused = pod.is_focused();
540 if link_focused {
541 pod.set_focused(false);
542 }
543 mark_orphan_if_live(ctx, link_focused);
544 }
545}
546
547/// Diff a child list against its live `ChildPod`s, extracting each child's
548/// [`AnyView`] through `view_of` (identity for a plain `Vec<AnyView>`, `|c|
549/// &c.view` for Flex's `FlexChild` wrapper) and its optional
550/// [`ChildKey`] through `key_of` (`|_| None` for keyless containers, `|c| c.key`
551/// for Flex) — the one shared reconciler for every multi-child container.
552///
553/// **Positional vs. keyed.** With *no* child carrying a key this is a positional
554/// diff (see `rebuild_children_positional`); with keys present it matches
555/// old↔new by key so reorders/inserts preserve widget identity and state (see
556/// `rebuild_children_keyed`). Keys are all-or-nothing per list: a list that
557/// mixes keyed and unkeyed children, or repeats a key, trips a `debug_assert`
558/// and falls back to the positional path (correct, just identity-blind).
559///
560/// **A structural change preserves focus/capture for unchanged siblings
561/// (Flutter's invariant).** A structural edit among siblings only cancels the
562/// recorded capture/focus paths of children whose own identity actually changed:
563/// the positional path preserves its stable prefix and cancels only the tail past
564/// the first type swap; the keyed path relocates a key-matched child's paths
565/// intact and cancels only a torn-down or type-swapped child. A
566/// structural-change-free rebuild leaves every recorded path untouched, so an
567/// ordinary every-frame rebuild never breaks a captured drag or dismisses the
568/// keyboard for an unchanged child.
569///
570/// **A child that loses a LIVE focus path takes the root's session with it.**
571/// Every route that severs a recorded focus path here — a torn-down child
572/// ([`teardown_child`]), a cleared tail ([`cancel_active_children`]), a
573/// key-reused type swap — raises
574/// [`mark_focus_orphaned`](frust_core::mark_focus_orphaned) *when the severed
575/// link was on the live focus chain* ([`mark_orphan_if_live`]), and
576/// `RenderRoot::rebuild` releases `focus_active`/`ime_state` before the frame
577/// ends rather than leaving them standing over a widget that no longer exists. A
578/// severed link that was merely a stale flag deep in an already-blurred branch
579/// marks nothing: it owned no session. See [`cancel_active_children`] for the
580/// mechanism.
581pub fn rebuild_children<State: 'static, C>(
582 prev: &[C],
583 next: &[C],
584 pods: &mut Vec<ChildPod>,
585 ctx: &mut BuildCtx<'_>,
586 view_of: impl Fn(&C) -> &AnyView<State>,
587 key_of: impl Fn(&C) -> Option<ChildKey>,
588) -> ChangeFlags {
589 let any_keyed = prev.iter().chain(next.iter()).any(|c| key_of(c).is_some());
590 if !any_keyed {
591 return rebuild_children_positional(prev, next, pods, ctx, view_of);
592 }
593 // v1 keys are all-or-nothing per list: a mixed list has no well-defined
594 // match for its unkeyed members, so fall back to positional (identity-blind
595 // but correct) rather than guess. A debug build flags the misuse loudly.
596 let all_keyed = prev.iter().chain(next.iter()).all(|c| key_of(c).is_some());
597 if !all_keyed {
598 debug_assert!(
599 false,
600 "keyed child list mixes keyed and unkeyed children; \
601 falling back to positional reconciliation"
602 );
603 return rebuild_children_positional(prev, next, pods, ctx, view_of);
604 }
605 rebuild_children_keyed(prev, next, pods, ctx, view_of, key_of)
606}
607
608/// The positional reconciler: common indices rebuild in place, a grown tail is
609/// built, a shrunk tail is torn down and dropped. Length changes signal
610/// `LAYOUT | PAINT`.
611///
612/// **Focus/capture survive a sibling structural change (Flutter's invariant).**
613/// A structural edit among SIBLINGS must not clear focus/IME (or an in-flight
614/// capture) for a child whose own identity is unchanged. Positional matching
615/// rebuilds each common index `i` in place against the *same* live widget, so a
616/// recorded focus/capture path to it stays valid as long as that slot was not an
617/// in-place type swap. We therefore compute the **stable prefix** `k` — the
618/// largest `k ≤ min(prev.len, next.len)` such that no index `< k` type-swapped
619/// (the first swap index, or `common` if none) — and preserve focus AND capture
620/// for pods `< k`. Only the tail from `k` onward has its recorded paths
621/// cancelled/cleared via [`cancel_active_children`]: a swapped slot (fresh widget
622/// that never saw `Down`), a shifted pod that may now hold different logical
623/// content, and the grown tail (fresh pods, nothing to unwind). A truncated
624/// active pod is cancelled earlier in [`teardown_child`]. A structural-change-free
625/// rebuild (same length, no swap) leaves every path untouched, so an ordinary
626/// every-frame rebuild never breaks a captured drag *or* dismisses the keyboard
627/// for an unchanged sibling (the huddle search-field bug this fixes).
628///
629/// This is Flutter's focus/IME retention behavior: only a child whose
630/// identity actually changes loses focus.
631/// Positional matching cannot distinguish a same-typed prepend from a
632/// content-change-plus-append — an index `< k` that positionally kept its widget
633/// but semantically moved keeps its recorded path (the documented positional
634/// limitation; a caller wanting identity across reorders uses `keyed`).
635fn rebuild_children_positional<State: 'static, C>(
636 prev: &[C],
637 next: &[C],
638 pods: &mut Vec<ChildPod>,
639 ctx: &mut BuildCtx<'_>,
640 view_of: impl Fn(&C) -> &AnyView<State>,
641) -> ChangeFlags {
642 let mut flags = ChangeFlags::NONE;
643 let common = prev.len().min(next.len());
644 // The stable prefix ends at the first in-place type swap (or at `common` if
645 // there is none): every index before it keeps the same live widget across
646 // the rebuild, so a recorded focus/capture path to it stays valid.
647 let mut first_swap: Option<usize> = None;
648 for i in 0..common {
649 let (child_flags, child_swapped) =
650 rebuild_child_tracked(view_of(&prev[i]), view_of(&next[i]), &mut pods[i], ctx);
651 flags |= child_flags;
652 if child_swapped {
653 // Fresh widget at this slot: drop the stale capture, nothing to cancel.
654 pods[i].set_active(false);
655 if first_swap.is_none() {
656 first_swap = Some(i);
657 }
658 }
659 }
660 if next.len() > prev.len() {
661 for child in &next[prev.len()..] {
662 pods.push(build_child(view_of(child), ctx));
663 }
664 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
665 } else if next.len() < prev.len() {
666 for (offset, child) in prev[next.len()..].iter().enumerate() {
667 teardown_child(view_of(child), &mut pods[next.len() + offset], ctx);
668 }
669 pods.truncate(next.len());
670 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
671 }
672 // On a structural change (length shift or in-place type swap), preserve the
673 // stable prefix (`< k`, same widget identity) and cancel/clear only the tail
674 // from `k` onward. On a pure length change with no swap, `k == common`, so the
675 // prefix (every surviving common pod) is preserved and only the grown tail —
676 // fresh pods with no recorded path — is "cancelled" (a no-op); a truncated
677 // active pod was already cancelled in `teardown_child`.
678 if prev.len() != next.len() || first_swap.is_some() {
679 let k = first_swap.unwrap_or(common);
680 // `ctx` carries the chain down to *this container*; each pod's own flag
681 // is ANDed onto it inside.
682 cancel_active_children(&mut pods[k..], ctx);
683 }
684 flags
685}
686
687/// The keyed reconciler: match old↔new children by [`ChildKey`] so reorders and
688/// inserts preserve each surviving child's live widget (and thus its internal
689/// state) instead of rebuilding whatever happens to sit at the same index.
690///
691/// Matched children are *relocated* into the new order — their `ChildPod` (boxed
692/// widget + geometry) is moved, then rebuilt in place against its own previous
693/// view. Unmatched new keys are built fresh; unmatched old keys are torn down
694/// (with [`teardown_child`]'s cancel-if-active). A duplicate key (old or new)
695/// trips a `debug_assert` and falls back to the positional path.
696///
697/// **A key-matched child keeps its focus/capture across a move (Flutter's
698/// invariant).** A reorder or insert relocates a matched child's whole
699/// `ChildPod` — including its `focused`/`active` bookkeeping — so its recorded
700/// path stays valid: focus/capture routing scans for the pod by its flag
701/// ([`ChildPod::is_focused`]/[`is_active`](ChildPod::is_active)), so a `Key`/`Ime`
702/// event (or a captured `Move`/`Up`) still reaches the relocated child at its new
703/// index with nothing to update in a parent index. Only a child whose identity
704/// actually breaks loses its path: a torn-down (removed) key is cancelled in
705/// [`teardown_child`], and a key reused for a different concrete type is a swap
706/// (the old widget died inside `AnyView::rebuild`) whose stale `active`/`focused`
707/// flags are dropped inline below. A same-keys, same-order rebuild is likewise a
708/// content-only rebuild that leaves every path untouched.
709fn rebuild_children_keyed<State: 'static, C>(
710 prev: &[C],
711 next: &[C],
712 pods: &mut Vec<ChildPod>,
713 ctx: &mut BuildCtx<'_>,
714 view_of: impl Fn(&C) -> &AnyView<State>,
715 key_of: impl Fn(&C) -> Option<ChildKey>,
716) -> ChangeFlags {
717 // Old key -> old index, flagging any duplicate.
718 let mut old_by_key: HashMap<ChildKey, usize> = HashMap::with_capacity(prev.len());
719 let mut duplicate = false;
720 for (i, child) in prev.iter().enumerate() {
721 let key = key_of(child).expect("all-keyed list checked by caller");
722 if old_by_key.insert(key, i).is_some() {
723 duplicate = true;
724 }
725 }
726 // Duplicate new keys are equally ambiguous (two children claim one identity).
727 let mut seen_new: HashSet<ChildKey> = HashSet::with_capacity(next.len());
728 for child in next {
729 let key = key_of(child).expect("all-keyed list checked by caller");
730 if !seen_new.insert(key) {
731 duplicate = true;
732 }
733 }
734 if duplicate {
735 debug_assert!(
736 false,
737 "keyed child list has duplicate keys; falling back to positional reconciliation"
738 );
739 return rebuild_children_positional(prev, next, pods, ctx, view_of);
740 }
741
742 // Take ownership of the old pods so matched ones can be relocated by `take`.
743 let mut old_pods: Vec<Option<ChildPod>> = pods.drain(..).map(Some).collect();
744 let mut new_pods: Vec<ChildPod> = Vec::with_capacity(next.len());
745 let mut flags = ChangeFlags::NONE;
746
747 for child in next {
748 let key = key_of(child).expect("all-keyed list checked by caller");
749 if let Some(&old_index) = old_by_key.get(&key) {
750 let mut pod = old_pods[old_index]
751 .take()
752 .expect("each old key matches at most one new child (no duplicates)");
753 let (child_flags, child_swapped) =
754 rebuild_child_tracked(view_of(&prev[old_index]), view_of(child), &mut pod, ctx);
755 flags |= child_flags;
756 if child_swapped {
757 // A key reused for a different concrete type: the old widget was
758 // torn down inside AnyView::rebuild, so its identity broke — drop
759 // the stale capture/focus paths (nothing armed to unwind). A
760 // key-matched non-swap relocates its pod (and thus its recorded
761 // focus/capture flags) intact, so no clearing happens there.
762 pod.set_active(false);
763 // The focused widget died inside the swap: the recorded focus
764 // path is severed, so the root's session goes with it — but only
765 // if this pod's link was on the LIVE chain; a stale flag under a
766 // blurred ancestor owns no session (see `mark_orphan_if_live` and
767 // `cancel_active_children`'s note).
768 let link_focused = pod.is_focused();
769 if link_focused {
770 pod.set_focused(false);
771 }
772 mark_orphan_if_live(ctx, link_focused);
773 }
774 new_pods.push(pod);
775 } else {
776 // A brand-new key: build a fresh child.
777 new_pods.push(build_child(view_of(child), ctx));
778 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
779 }
780 }
781
782 // Any old pod never taken is a removed key: tear it down (cancelling first if
783 // it held an in-flight capture, and dropping its focus flag with it).
784 for (old_index, slot) in old_pods.iter_mut().enumerate() {
785 if let Some(mut pod) = slot.take() {
786 teardown_child(view_of(&prev[old_index]), &mut pod, ctx);
787 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
788 }
789 }
790
791 *pods = new_pods;
792 flags
793}
794
795/// A field shape a container can hold children in, visitable in declaration
796/// order — the traversal half of the [`visit_children!`] seam.
797///
798/// Implemented for [`ChildPod`] and, generically, for `Option<T>`/`Vec<T>`/
799/// `[T]` over anything visitable, so the three shapes containers actually use
800/// (`child: ChildPod`, `icon: Option<ChildPod>`, `children: Vec<ChildPod>`) are
801/// covered by one impl each. A container holding pods inside its own row/slot
802/// struct implements this for that struct and stays on the same seam.
803pub trait VisitPods {
804 /// Hand each pod this value holds to `visitor`, in declaration order.
805 fn visit_pods(&self, visitor: &mut dyn FnMut(&ChildPod));
806}
807
808impl VisitPods for ChildPod {
809 fn visit_pods(&self, visitor: &mut dyn FnMut(&ChildPod)) {
810 visitor(self);
811 }
812}
813
814impl<T: VisitPods> VisitPods for Option<T> {
815 fn visit_pods(&self, visitor: &mut dyn FnMut(&ChildPod)) {
816 if let Some(inner) = self {
817 inner.visit_pods(visitor);
818 }
819 }
820}
821
822impl<T: VisitPods> VisitPods for [T] {
823 fn visit_pods(&self, visitor: &mut dyn FnMut(&ChildPod)) {
824 for item in self {
825 item.visit_pods(visitor);
826 }
827 }
828}
829
830impl<T: VisitPods> VisitPods for Vec<T> {
831 fn visit_pods(&self, visitor: &mut dyn FnMut(&ChildPod)) {
832 self.as_slice().visit_pods(visitor);
833 }
834}
835
836/// Implement [`Widget::visit_children`](frust_core::Widget::visit_children) for
837/// the container whose `impl Widget` block this is written in, forwarding the
838/// named fields in the order given.
839///
840/// The read-only introspection seam that makes a container's retained subtree
841/// enumerable (`frust-core`'s `WidgetTree::inspect` is the consumer). One line
842/// per container, so the traversal lives here rather than being re-hand-written
843/// per widget:
844///
845/// ```
846/// # use frust_core::{BoxConstraints, ChildPod, LayoutCtx, PaintCtx, PaintScene, Widget};
847/// # use kurbo::Size;
848/// use frust_widgets::authoring::visit_children;
849///
850/// struct RowWidget {
851/// leading: Option<ChildPod>,
852/// children: Vec<ChildPod>,
853/// }
854///
855/// impl Widget for RowWidget {
856/// # fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size { bc.max() }
857/// # fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
858/// // ...layout/paint/event/semantics as usual...
859/// visit_children!(leading, children);
860/// }
861/// ```
862///
863/// Each field's type only has to implement [`VisitPods`] — `ChildPod`,
864/// `Option<_>`, `Vec<_>`, or a container's own row struct. A widget whose
865/// children are reached some other way (a slot list behind an enum, a
866/// transition's stashed page) writes the method by hand instead; the contract is
867/// the same either way — visit every pod you own, in paint order, and nothing
868/// else.
869#[macro_export]
870macro_rules! visit_children {
871 ($($field:ident),* $(,)?) => {
872 fn visit_children(&self, visitor: &mut dyn FnMut(&$crate::authoring::ChildPod)) {
873 $( $crate::authoring::VisitPods::visit_pods(&self.$field, visitor); )*
874 }
875 };
876}
877
878pub use crate::visit_children;
879/// Re-exported so the [`visit_children!`] expansion can name the type without
880/// the call site importing it.
881pub use frust_core::ChildPod;
882
883/// Whether `event` is the phase that auto-releases a recorded capture
884/// (`Up`/`Cancel`) — shared by [`route_event`]/[`route_event_single`] and by
885/// [`OverlaySlot`](crate::overlay::OverlaySlot), which dispatches into its pod
886/// without going through either and owes the pod's `active` link the same
887/// lifetime every other container gives a captured child.
888///
889/// `frust-core`'s `component::route_child` keeps a copy of its own: that crate
890/// sits *below* this one, so it cannot name this function. It is the one
891/// remaining duplicate of this predicate in the workspace.
892pub(crate) fn releases_capture(event: &InputEvent) -> bool {
893 matches!(
894 event,
895 InputEvent::Pointer(p)
896 if matches!(p.phase, PointerPhase::Up | PointerPhase::Cancel)
897 )
898}
899
900/// Whether `p` carries a button that may begin a press.
901///
902/// A press/activation machine — a `pressed` visual, a pointer capture, an
903/// up-inside callback, a drag — starts on the **primary** button alone: the left
904/// mouse button, or any touch/pen contact (which every shell reports as
905/// [`PointerButton::Primary`] too). A secondary press is a context gesture: no
906/// baseline widget does anything with it, so it is left for a context-menu
907/// consumer instead of activating the control under the cursor.
908///
909/// Move/hover arms deliberately do **not** consult this: hover chrome and cursor
910/// shapes are position-driven, and a move carries no meaningful button. Neither
911/// do the focus-session arms — [`is_pointer_down`]'s blur-on-outside-tap, and a
912/// widget's own `request_focus`/IME publish — because the root reads *any* `Down`
913/// that bubbles no claim as a blur, whatever button carried it.
914///
915/// `frust_shadcn`'s `hit::presses` is the same predicate for that catalog; each
916/// catalog keeps its own copy rather than depending on a sibling crate.
917pub(crate) fn presses(p: &PointerEvent) -> bool {
918 p.button == PointerButton::Primary
919}
920
921/// Whether `event` is a pointer `Down` — the phase that both opens a capture and
922/// triggers blur-on-outside-tap evaluation in the routing helpers.
923fn is_pointer_down(event: &InputEvent) -> bool {
924 matches!(
925 event,
926 InputEvent::Pointer(p) if matches!(p.phase, PointerPhase::Down)
927 )
928}
929
930/// Route a pointer/scroll/keyboard/IME event — or a broadcast — to a
931/// container's children.
932///
933/// **Broadcasts** ([`InputEvent::is_broadcast`], today
934/// [`InputEvent::Housekeeping`]) are checked **first**, ahead of the capture,
935/// focus, and hit-test branches: every child receives one unconditionally, in
936/// order, and the container always reports [`EventResult::Ignored`] so no
937/// first-handler-wins short-circuit can hide a subtree. This is what carries a
938/// deferred, state-bearing callback flush (a navigator's queued `on_result`) to
939/// wherever it was queued, on the frame that queued it, with no user input — see
940/// [`InputEvent::Housekeeping`].
941///
942/// **Focus-routed events** ([`InputEvent::Key`]/[`InputEvent::Ime`]) bypass hit
943/// testing entirely: they go straight to the child holding a focus link **on the
944/// live session** ([`ChildPod::holds_live_focus`]), or are ignored if none does.
945/// This is the focus mirror of the capture fast-path.
946///
947/// The liveness half is not a refinement, it is the question. Two children can
948/// carry a recorded link at the same time — the blur sweep below runs on a
949/// hit-tested `Down`, and a claim made from a broadcast (an overlay surface's
950/// own press, which reaches no container's hit test) never triggers it — so a
951/// container that answered "which child is focused?" with the first flagged one
952/// delivered the keyboard by child order rather than by who holds the session.
953/// A stamp comparison answers it by who actually claimed it last.
954///
955/// For **pointer/scroll**: a captured gesture goes straight to the recorded
956/// active child (capture auto-releases on `Up`/`Cancel`). Otherwise the children
957/// are hit-tested in reverse paint order — topmost (last-painted) first — and the
958/// first child that both contains the point and reports [`EventResult::Handled`]
959/// consumes it. This is the z-order convention Flex establishes and Stack shares.
960///
961/// **Blur-on-outside-tap:** a pointer `Down` that does not (re)establish focus on
962/// the child it hits clears every focused child in this container. At the nearest
963/// common ancestor of a stale focus branch and the tapped branch, this breaks the
964/// recorded focus chain (a `Down` inside the still-focused child keeps it — that
965/// child stays focused and is not cleared).
966///
967/// **Deeper stale flags below a cleared link are harmless by construction**, not
968/// merely "corrected later". Nothing reads a `focused` flag on its own: every
969/// consumer composes it with the chain above it, so a cleared link forces the
970/// whole subtree below to read as unfocused. Focus-routed events stop at the
971/// cleared link (this function finds no live-linked child to forward to); paint
972/// ANDs the same way (`ChildPod::paint_child` composes the link, its stamp and
973/// the chain); and the rebuild pass ANDs the same way too
974/// ([`BuildCtx::has_focus`]), so tearing a stale branch down marks no orphan and
975/// cannot release the session of the field that really is focused
976/// ([`mark_orphan_if_live`]). The flags themselves are still cleaned up the next
977/// time focus enters that subtree (a focus request re-records the whole chain).
978pub fn route_event(
979 children: &mut [ChildPod],
980 ctx: &mut EventCtx<'_>,
981 event: &InputEvent,
982) -> EventResult {
983 if event.is_broadcast() {
984 for pod in children.iter_mut() {
985 // Results are discarded on purpose: a broadcast is never consumed,
986 // so every child gets it regardless of what an earlier one returned.
987 pod.event_child(ctx, event);
988 }
989 return EventResult::Ignored;
990 }
991 if event.is_focus_routed() {
992 if let Some(pod) = children.iter_mut().find(|p| p.holds_live_focus()) {
993 return pod.event_child(ctx, event);
994 }
995 return EventResult::Ignored;
996 }
997 if let Some(pod) = children.iter_mut().find(|p| p.is_active()) {
998 return route_event_single(pod, ctx, event);
999 }
1000 let position = event.position();
1001 let mut handled = EventResult::Ignored;
1002 // The index of the hit child *if* it holds focus after dispatch — the one
1003 // focused child blur-on-Down must preserve.
1004 let mut kept_focus: Option<usize> = None;
1005 let n = children.len();
1006 for i in (0..n).rev() {
1007 if children[i].contains(position)
1008 && children[i].event_child(ctx, event) == EventResult::Handled
1009 {
1010 if children[i].holds_live_focus() {
1011 kept_focus = Some(i);
1012 }
1013 handled = EventResult::Handled;
1014 break;
1015 }
1016 }
1017 if is_pointer_down(event) {
1018 for (i, pod) in children.iter_mut().enumerate() {
1019 if Some(i) != kept_focus && pod.is_focused() {
1020 pod.set_focused(false);
1021 }
1022 }
1023 }
1024 handled
1025}
1026
1027/// Route a pointer/scroll event — or a broadcast — to a container's single
1028/// child.
1029///
1030/// Mirrors [`route_event`] for the one-child wrappers (`Padding`/`Align`/
1031/// `SizedBox`): a broadcast ([`InputEvent::is_broadcast`]) reaches the child
1032/// unconditionally and is never consumed (checked first, ahead of everything
1033/// else); a captured gesture is forwarded to `pod` unconditionally —
1034/// regardless of whether the event's position still falls within the child's
1035/// bounds — with the active path cleared on `Up`/`Cancel`; otherwise the child
1036/// only receives the event if it contains the point. Re-hit-testing
1037/// `pod.contains()` on every event instead of consulting [`ChildPod::is_active`]
1038/// is the bug this helper exists to prevent — see `ChildPod::contains`'s docs.
1039pub fn route_event_single(
1040 pod: &mut ChildPod,
1041 ctx: &mut EventCtx<'_>,
1042 event: &InputEvent,
1043) -> EventResult {
1044 if event.is_broadcast() {
1045 // Never consumed: forward, discard the result.
1046 pod.event_child(ctx, event);
1047 return EventResult::Ignored;
1048 }
1049 if event.is_focus_routed() {
1050 // Focus-routed events go to the child only if its recorded link belongs
1051 // to the live session — see [`route_event`] for why the liveness half is
1052 // load-bearing rather than cosmetic.
1053 if pod.holds_live_focus() {
1054 return pod.event_child(ctx, event);
1055 }
1056 return EventResult::Ignored;
1057 }
1058 if pod.is_active() {
1059 let result = pod.event_child(ctx, event);
1060 if releases_capture(event) {
1061 pod.set_active(false);
1062 }
1063 return result;
1064 }
1065 let inside = pod.contains(event.position());
1066 let result = if inside {
1067 pod.event_child(ctx, event)
1068 } else {
1069 EventResult::Ignored
1070 };
1071 // Blur-on-outside-tap: a `Down` that lands *outside* the (single) focused
1072 // child drops its recorded focus path. A `Down` inside the child keeps focus
1073 // (the child re-requests it, or simply stays the focused widget).
1074 if is_pointer_down(event) && !inside && pod.is_focused() {
1075 pod.set_focused(false);
1076 }
1077 result
1078}
1079
1080/// Compile-time proof that the whole authoring toolkit is reachable through the
1081/// **public** `crate::authoring::` path, not just the crate-root re-export the
1082/// in-crate call sites use.
1083///
1084/// Every item is named through `crate::authoring::…` and bound to an explicit
1085/// type, so an accidental re-privatization (or a signature change) fails the
1086/// build here rather than silently breaking a downstream design-system crate.
1087/// The module doc's worked example is the companion check: rustdoc compiles it as
1088/// a genuinely external crate.
1089#[cfg(test)]
1090mod authoring_surface_tests {
1091 use frust_core::{AnyView, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent};
1092
1093 #[test]
1094 // The point of every binding below is the spelled-out type: it is what makes
1095 // a re-privatization or a signature change fail the build here.
1096 #[allow(clippy::type_complexity)]
1097 fn every_promoted_item_is_reachable_through_the_authoring_path() {
1098 // Callback erasure: the two adapters, the typed input, the two erasers.
1099 let _erased: Option<crate::authoring::ErasedCallback> = None;
1100 let _erased_arg: Option<crate::authoring::ErasedArgCallback<f64>> = None;
1101 let _typed: Option<crate::authoring::TypedArgCallback<(), f64>> = None;
1102 let _erase: fn(&std::rc::Rc<dyn Fn(&mut ())>) -> crate::authoring::ErasedCallback =
1103 crate::authoring::erase_callback::<()>;
1104 let _erase_arg: fn(
1105 &crate::authoring::TypedArgCallback<(), f64>,
1106 ) -> crate::authoring::ErasedArgCallback<f64> =
1107 crate::authoring::erase_callback_arg::<(), f64>;
1108
1109 // Child plumbing: build/rebuild/teardown one child, reconcile many.
1110 let _build: fn(&AnyView<()>, &mut BuildCtx<'_>) -> ChildPod =
1111 crate::authoring::build_child::<()>;
1112 let _rebuild: fn(
1113 &AnyView<()>,
1114 &AnyView<()>,
1115 &mut ChildPod,
1116 &mut BuildCtx<'_>,
1117 ) -> ChangeFlags = crate::authoring::rebuild_child::<()>;
1118 let _teardown: fn(&AnyView<()>, &mut ChildPod, &mut BuildCtx<'_>) =
1119 crate::authoring::teardown_child::<()>;
1120 // `rebuild_children` takes `impl Fn` closures, which cannot be
1121 // turbofished into a `fn` pointer like the others — call it instead
1122 // (empty lists: the point is the path, not the reconciliation).
1123 let mut counter = 0u64;
1124 let mut build_ctx = BuildCtx::new(&mut counter);
1125 let mut pods: Vec<ChildPod> = Vec::new();
1126 let empty: Vec<AnyView<()>> = Vec::new();
1127 crate::authoring::rebuild_children(
1128 &empty,
1129 &empty,
1130 &mut pods,
1131 &mut build_ctx,
1132 |v: &AnyView<()>| v,
1133 |_: &AnyView<()>| None::<crate::ChildKey>,
1134 );
1135 assert!(pods.is_empty());
1136
1137 // Event routing: multi-child and single-child.
1138 let _route: fn(&mut [ChildPod], &mut EventCtx<'_>, &InputEvent) -> EventResult =
1139 crate::authoring::route_event;
1140 let _route_single: fn(&mut ChildPod, &mut EventCtx<'_>, &InputEvent) -> EventResult =
1141 crate::authoring::route_event_single;
1142
1143 // Themed text roles (color and family): each enum here, each builder
1144 // method on `TextView`.
1145 let _role: crate::authoring::ThemeTextColor = crate::authoring::ThemeTextColor::OnSurface;
1146 let _themed_role: fn(crate::TextView, crate::authoring::ThemeTextColor) -> crate::TextView =
1147 crate::TextView::themed_role;
1148 let _type: crate::authoring::ThemeTextType = crate::authoring::ThemeTextType::BodyLarge;
1149 let _themed_family: fn(
1150 crate::TextView,
1151 crate::authoring::ThemeTextType,
1152 ) -> crate::TextView = crate::TextView::themed_family;
1153
1154 // The shared press-overlay opacity.
1155 let _pressed: f32 = crate::authoring::PRESSED_OPACITY;
1156
1157 // The overlay portal: the placement math, the request it takes, the
1158 // slot an owner hosts its floated surface in, and the registry
1159 // vocabulary that slot is configured with.
1160 let _place: fn(
1161 kurbo::Rect,
1162 kurbo::Size,
1163 kurbo::Rect,
1164 crate::authoring::OverlayPlacement,
1165 ) -> kurbo::Rect = crate::authoring::place;
1166 let _side: crate::authoring::OverlaySide = crate::authoring::OverlaySide::Top;
1167 let _align: crate::authoring::OverlayAlign = crate::authoring::OverlayAlign::Center;
1168 let _anchor: crate::authoring::OverlayAnchor = crate::authoring::OverlayAnchor::Owner;
1169 let slot: crate::authoring::OverlaySlot<()> = crate::authoring::OverlaySlot::new();
1170 let _key: crate::authoring::OverlayKey = slot.key();
1171 let _band: crate::authoring::OverlayBand = crate::authoring::OverlayBand::Floating;
1172 let _input: crate::authoring::OverlayInput = crate::authoring::OverlayInput::Interactive;
1173 let _tap: crate::authoring::OutsideTap =
1174 crate::authoring::OutsideTap::Notify { consume: true };
1175
1176 // Child visitation: the trait, its three shape impls, and the macro
1177 // that writes the `Widget::visit_children` body from them.
1178 let _visit: fn(&ChildPod, &mut dyn FnMut(&ChildPod)) =
1179 <ChildPod as crate::authoring::VisitPods>::visit_pods;
1180 let _visit_opt: fn(&Option<ChildPod>, &mut dyn FnMut(&ChildPod)) =
1181 <Option<ChildPod> as crate::authoring::VisitPods>::visit_pods;
1182 let _visit_vec: fn(&Vec<ChildPod>, &mut dyn FnMut(&ChildPod)) =
1183 <Vec<ChildPod> as crate::authoring::VisitPods>::visit_pods;
1184 // The macro's own reachability is proven by its doc example, which
1185 // rustdoc compiles as a genuinely external crate.
1186 }
1187}
1188
1189/// Mechanism-level tests for [`rebuild_child`]'s type-swap capture *and focus*
1190/// handling, plus the orphan-marking sites of the multi-child reconcilers:
1191/// every single-child container (`Padding`/`Align`/
1192/// `SizedBox`, interactive widgets' labels) reconciles its child through this
1193/// helper, so the clear-on-swap behavior is proven once here at the shared
1194/// substrate, and each container's own test module (see `padding`/`align`/
1195/// `sized`) proves it end to end through real event routing/geometry.
1196#[cfg(test)]
1197mod tests {
1198 use super::*;
1199 use crate::test_support::{leaf_any, swap_leaf};
1200 use frust_core::{BoxConstraints, BuildCtx, LayoutCtx, PaintCtx, PaintScene, any};
1201 use kurbo::Size;
1202
1203 /// A context on the **live** focus chain — what a diff under a real focused
1204 /// root sees (`BuildCtx::new`'s "unknown, assume live" default).
1205 fn ctx(counter: &mut u64) -> BuildCtx<'_> {
1206 BuildCtx::new(counter)
1207 }
1208
1209 /// A context whose focus chain was broken by an ancestor: the diff of a
1210 /// subtree hanging below a link a container-routed blur already cleared.
1211 fn blurred_ctx(counter: &mut u64) -> BuildCtx<'_> {
1212 let mut ctx = BuildCtx::new(counter);
1213 ctx.set_has_focus(false);
1214 ctx
1215 }
1216
1217 /// A minimal multi-child container **view/widget pair** — the smallest thing
1218 /// that reconciles a child list through [`rebuild_children`] — so the focus
1219 /// chain can be exercised through genuine nesting (outer container → inner
1220 /// container → leaf) rather than one level of pods.
1221 struct NestView {
1222 children: Vec<AnyView<()>>,
1223 }
1224
1225 struct NestWidget {
1226 children: Vec<ChildPod>,
1227 }
1228
1229 impl View<()> for NestView {
1230 type Element = NestWidget;
1231
1232 fn build(&self, ctx: &mut BuildCtx<'_>) -> NestWidget {
1233 NestWidget {
1234 children: self.children.iter().map(|c| build_child(c, ctx)).collect(),
1235 }
1236 }
1237
1238 fn rebuild(
1239 &self,
1240 prev: &Self,
1241 element: &mut NestWidget,
1242 ctx: &mut BuildCtx<'_>,
1243 ) -> ChangeFlags {
1244 rebuild_children(
1245 &prev.children,
1246 &self.children,
1247 &mut element.children,
1248 ctx,
1249 |v: &AnyView<()>| v,
1250 |_: &AnyView<()>| None::<ChildKey>,
1251 )
1252 }
1253
1254 fn teardown(&self, element: &mut NestWidget, ctx: &mut BuildCtx<'_>) {
1255 for (view, pod) in self.children.iter().zip(element.children.iter_mut()) {
1256 teardown_child(view, pod, ctx);
1257 }
1258 }
1259 }
1260
1261 impl Widget for NestWidget {
1262 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1263 bc.constrain(Size::new(10.0, 10.0))
1264 }
1265 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1266 }
1267
1268 /// Reach the [`NestWidget`] inside a pod built from a [`NestView`] (double-boxed
1269 /// by [`build_child`]), so a test can set a pod flag deep in the tree.
1270 fn nest_in(pod: &mut ChildPod) -> &mut NestWidget {
1271 let boxed = pod
1272 .widget_mut()
1273 .downcast_mut::<Box<dyn Widget>>()
1274 .expect("pod holds a boxed AnyView widget");
1275 (**boxed)
1276 .downcast_mut::<NestWidget>()
1277 .expect("the boxed widget is the NestWidget")
1278 }
1279
1280 #[test]
1281 fn rebuild_child_clears_active_on_type_swap() {
1282 // A type swap (Leaf -> SwapLeaf, different concrete types) tears down
1283 // the old widget inside AnyView::rebuild and builds a fresh one in its
1284 // place; a still-active pod must have its stale capture dropped so a
1285 // later event isn't misrouted into the fresh widget.
1286 let mut counter = 0u64;
1287 let prev: AnyView<()> = leaf_any(10.0, 10.0);
1288 let mut pod = build_child(&prev, &mut ctx(&mut counter));
1289 pod.set_active(true);
1290
1291 let next: AnyView<()> = swap_leaf().into_any();
1292 rebuild_child::<()>(&prev, &next, &mut pod, &mut ctx(&mut counter));
1293
1294 assert!(
1295 !pod.is_active(),
1296 "a type-swapped child's stale capture must be dropped"
1297 );
1298 }
1299
1300 #[test]
1301 fn rebuild_child_preserves_active_on_same_type_rebuild() {
1302 // Negative guard against over-clearing: a same-type, content-only
1303 // rebuild (no AnyView type swap) must NOT touch an in-flight capture —
1304 // mirrors rebuild_children's content_only_rebuild_preserves_captured_drag.
1305 let mut counter = 0u64;
1306 let prev: AnyView<()> = leaf_any(10.0, 10.0);
1307 let mut pod = build_child(&prev, &mut ctx(&mut counter));
1308 pod.set_active(true);
1309
1310 let next: AnyView<()> = leaf_any(20.0, 20.0);
1311 rebuild_child::<()>(&prev, &next, &mut pod, &mut ctx(&mut counter));
1312
1313 assert!(
1314 pod.is_active(),
1315 "a content-only rebuild must not clear an in-flight capture"
1316 );
1317 }
1318
1319 /// [`rebuild_child`]'s type-swap marking site — the single-child wrappers
1320 /// (`Padding`/`Align`/`SizedBox`/`GestureDetector`/`Scroll`, the card/dialog
1321 /// slot helpers, `ListView`'s surviving rows) — pinned in both directions.
1322 /// `Padding(if editing { text_input } else { text })` is the shipped shape:
1323 /// the focused widget dies inside the swap, so a live session dies with it,
1324 /// while the same swap over a stale flag under a blurred ancestor owns no
1325 /// session to lose.
1326 #[test]
1327 fn a_wrapper_type_swap_marks_only_on_a_live_chain() {
1328 fn swap_wrapped_child(build: &mut dyn FnMut(&mut u64) -> BuildCtx<'_>) -> bool {
1329 let _ = frust_core::take_focus_orphaned();
1330 let mut counter = 0u64;
1331 let prev: AnyView<()> = leaf_any(10.0, 10.0);
1332 let mut pod = build_child(&prev, &mut ctx(&mut counter));
1333 // The wrapper's single child holds the recorded focus path.
1334 pod.set_focused(true);
1335
1336 // Leaf -> SwapLeaf: a concrete-type change, so the old widget dies
1337 // inside `AnyView::rebuild` and a fresh one takes its pod.
1338 let next: AnyView<()> = swap_leaf().into_any();
1339 rebuild_child::<()>(&prev, &next, &mut pod, &mut build(&mut counter));
1340 assert!(
1341 !pod.is_focused(),
1342 "a swapped-in widget must not inherit the old focus link"
1343 );
1344 frust_core::take_focus_orphaned()
1345 }
1346
1347 assert!(
1348 swap_wrapped_child(&mut ctx),
1349 "a live focus path dying inside a wrapper's type swap releases the session"
1350 );
1351 assert!(
1352 !swap_wrapped_child(&mut blurred_ctx),
1353 "the same swap over a stale flag below a cleared link marks nothing"
1354 );
1355 }
1356
1357 /// The negative guard for the site above: a same-type, content-only rebuild
1358 /// through a wrapper keeps the focus path (Flutter's retention invariant),
1359 /// so it must neither clear the flag nor mark — a mark here would drop the
1360 /// keyboard on every frame a padded field re-renders.
1361 #[test]
1362 fn a_wrapper_content_only_rebuild_keeps_focus_and_marks_nothing() {
1363 let _ = frust_core::take_focus_orphaned();
1364 let mut counter = 0u64;
1365 let prev: AnyView<()> = leaf_any(10.0, 10.0);
1366 let mut pod = build_child(&prev, &mut ctx(&mut counter));
1367 pod.set_focused(true);
1368
1369 let next: AnyView<()> = leaf_any(20.0, 20.0);
1370 rebuild_child::<()>(&prev, &next, &mut pod, &mut ctx(&mut counter));
1371
1372 assert!(
1373 pod.is_focused(),
1374 "a content-only rebuild must not clear the recorded focus path"
1375 );
1376 assert!(
1377 !frust_core::take_focus_orphaned(),
1378 "an unchanged wrapper child must not orphan the focus session"
1379 );
1380 }
1381
1382 /// The producer half of the generic-unmount focus release: a reconciler that
1383 /// severs a recorded focus path raises the orphan mark, which
1384 /// `RenderRoot::rebuild` drains into a full session release (the consumer
1385 /// half is pinned in `frust-core`'s
1386 /// `generic_unmount_orphan_releases_the_whole_session`).
1387 #[test]
1388 fn a_truncated_focused_child_marks_the_focus_orphan() {
1389 let _ = frust_core::take_focus_orphaned();
1390 let mut counter = 0u64;
1391 let prev: Vec<AnyView<()>> = vec![leaf_any(10.0, 10.0), leaf_any(10.0, 10.0)];
1392 let mut pods: Vec<ChildPod> = prev
1393 .iter()
1394 .map(|v| build_child(v, &mut ctx(&mut counter)))
1395 .collect();
1396 // The second child holds the recorded focus path.
1397 pods[1].set_focused(true);
1398 assert!(
1399 !frust_core::take_focus_orphaned(),
1400 "building a list orphans nothing"
1401 );
1402
1403 // Shrink the list: the focused child is torn down and dropped.
1404 let next: Vec<AnyView<()>> = vec![leaf_any(10.0, 10.0)];
1405 rebuild_children(
1406 &prev,
1407 &next,
1408 &mut pods,
1409 &mut ctx(&mut counter),
1410 |v: &AnyView<()>| v,
1411 |_: &AnyView<()>| None::<ChildKey>,
1412 );
1413 assert_eq!(pods.len(), 1);
1414 assert!(
1415 frust_core::take_focus_orphaned(),
1416 "the focused child's unmount must be reported to the render root"
1417 );
1418 }
1419
1420 /// The cross-branch guard for the [`teardown_child`] marking site: the same
1421 /// truncation as above, but with the container's own link to the root already
1422 /// cleared (a blur moved focus to another branch, leaving this pod's flag
1423 /// stale). The stale pod owns no session, so its unmount must mark nothing —
1424 /// marking here is what dropped the keyboard out of an unrelated, still-live
1425 /// field when a list recycled a row.
1426 #[test]
1427 fn a_truncated_stale_focused_child_under_a_blurred_link_marks_nothing() {
1428 let _ = frust_core::take_focus_orphaned();
1429 let mut counter = 0u64;
1430 let prev: Vec<AnyView<()>> = vec![leaf_any(10.0, 10.0), leaf_any(10.0, 10.0)];
1431 let mut pods: Vec<ChildPod> = prev
1432 .iter()
1433 .map(|v| build_child(v, &mut ctx(&mut counter)))
1434 .collect();
1435 // Stale: the flag is set, but the chain above this container is broken.
1436 pods[1].set_focused(true);
1437
1438 let next: Vec<AnyView<()>> = vec![leaf_any(10.0, 10.0)];
1439 rebuild_children(
1440 &prev,
1441 &next,
1442 &mut pods,
1443 &mut blurred_ctx(&mut counter),
1444 |v: &AnyView<()>| v,
1445 |_: &AnyView<()>| None::<ChildKey>,
1446 );
1447 assert_eq!(pods.len(), 1);
1448 assert!(
1449 !frust_core::take_focus_orphaned(),
1450 "a stale flag below a cleared link owns no session — releasing here \
1451 would kill the focus of whatever field really is focused"
1452 );
1453 }
1454
1455 /// The [`cancel_active_children`] marking site (the cleared tail past the
1456 /// first type swap), pinned in both directions: it marks on a live chain and
1457 /// stays silent under a blurred one.
1458 #[test]
1459 fn a_cleared_tail_marks_only_on_a_live_chain() {
1460 // Index 0 type-swaps (Leaf -> SwapLeaf), so the tail from 0 onward has
1461 // its recorded paths cleared; index 1 holds the focus flag.
1462 fn shrink_tail(build: &mut dyn FnMut(&mut u64) -> BuildCtx<'_>) -> bool {
1463 let _ = frust_core::take_focus_orphaned();
1464 let mut counter = 0u64;
1465 let prev: Vec<AnyView<()>> = vec![leaf_any(10.0, 10.0), leaf_any(10.0, 10.0)];
1466 let mut pods: Vec<ChildPod> = prev
1467 .iter()
1468 .map(|v| build_child(v, &mut ctx(&mut counter)))
1469 .collect();
1470 pods[1].set_focused(true);
1471 let next: Vec<AnyView<()>> = vec![swap_leaf().into_any(), leaf_any(10.0, 10.0)];
1472 rebuild_children(
1473 &prev,
1474 &next,
1475 &mut pods,
1476 &mut build(&mut counter),
1477 |v: &AnyView<()>| v,
1478 |_: &AnyView<()>| None::<ChildKey>,
1479 );
1480 assert!(!pods[1].is_focused(), "the cleared tail drops the flag");
1481 frust_core::take_focus_orphaned()
1482 }
1483
1484 assert!(
1485 shrink_tail(&mut ctx),
1486 "a live focus link cleared by the tail sweep releases the session"
1487 );
1488 assert!(
1489 !shrink_tail(&mut blurred_ctx),
1490 "the same sweep over a stale flag below a cleared link marks nothing"
1491 );
1492 }
1493
1494 /// The keyed reconciler's type-swap marking site, pinned in both directions:
1495 /// a key reused for a different concrete type breaks identity, so a *live*
1496 /// focus link dies with it — but a stale one under a blurred ancestor does
1497 /// not.
1498 #[test]
1499 fn a_keyed_type_swap_marks_only_on_a_live_chain() {
1500 fn swap_keyed(build: &mut dyn FnMut(&mut u64) -> BuildCtx<'_>) -> bool {
1501 let _ = frust_core::take_focus_orphaned();
1502 let mut counter = 0u64;
1503 let key = ChildKey::new(1u32);
1504 let prev: Vec<(AnyView<()>, ChildKey)> = vec![(leaf_any(10.0, 10.0), key)];
1505 let mut pods: Vec<ChildPod> = prev
1506 .iter()
1507 .map(|c| build_child(&c.0, &mut ctx(&mut counter)))
1508 .collect();
1509 pods[0].set_focused(true);
1510 // Same key, different concrete view type: a swap, not a relocation.
1511 let next: Vec<(AnyView<()>, ChildKey)> = vec![(swap_leaf().into_any(), key)];
1512 rebuild_children(
1513 &prev,
1514 &next,
1515 &mut pods,
1516 &mut build(&mut counter),
1517 |c: &(AnyView<()>, ChildKey)| &c.0,
1518 |c: &(AnyView<()>, ChildKey)| Some(c.1),
1519 );
1520 assert!(!pods[0].is_focused(), "a swapped-away pod drops the flag");
1521 frust_core::take_focus_orphaned()
1522 }
1523
1524 assert!(
1525 swap_keyed(&mut ctx),
1526 "a live focus path dying inside a keyed swap releases the session"
1527 );
1528 assert!(
1529 !swap_keyed(&mut blurred_ctx),
1530 "a stale flag dying inside a keyed swap releases nothing"
1531 );
1532 }
1533
1534 /// The chain must compose through **nesting**, root → container → child: the
1535 /// blur clears the link at the nearest common ancestor (the outer container's
1536 /// pod), and the leaf's own flag two levels down stays stale. Tearing that
1537 /// leaf out must mark nothing — while the identical teardown with the outer
1538 /// link intact must still mark.
1539 #[test]
1540 fn the_focus_chain_composes_through_nested_containers() {
1541 fn drop_inner_leaf(outer_link_focused: bool) -> bool {
1542 let _ = frust_core::take_focus_orphaned();
1543 let mut counter = 0u64;
1544 let prev: Vec<AnyView<()>> = vec![any(NestView {
1545 children: vec![leaf_any(10.0, 10.0)],
1546 })];
1547 let mut pods: Vec<ChildPod> = prev
1548 .iter()
1549 .map(|v| build_child(v, &mut ctx(&mut counter)))
1550 .collect();
1551 // The leaf deep inside holds the focus flag either way; only the
1552 // OUTER link differs — that is the whole experiment.
1553 nest_in(&mut pods[0]).children[0].set_focused(true);
1554 pods[0].set_focused(outer_link_focused);
1555
1556 // The inner container loses its only child (a filtered/recycled row).
1557 let next: Vec<AnyView<()>> = vec![any(NestView { children: vec![] })];
1558 rebuild_children(
1559 &prev,
1560 &next,
1561 &mut pods,
1562 &mut ctx(&mut counter),
1563 |v: &AnyView<()>| v,
1564 |_: &AnyView<()>| None::<ChildKey>,
1565 );
1566 assert!(
1567 nest_in(&mut pods[0]).children.is_empty(),
1568 "the inner child was torn down"
1569 );
1570 frust_core::take_focus_orphaned()
1571 }
1572
1573 assert!(
1574 drop_inner_leaf(true),
1575 "an unbroken chain root→container→leaf is a live session; its unmount releases it"
1576 );
1577 assert!(
1578 !drop_inner_leaf(false),
1579 "a cleared link at the nearest common ancestor makes every flag below it inert"
1580 );
1581 }
1582
1583 /// The negative guard: an ordinary content-only rebuild keeps the focus path
1584 /// (Flutter's retention invariant) and must therefore orphan nothing — a mark
1585 /// raised here would drop the keyboard on every frame.
1586 #[test]
1587 fn a_content_only_rebuild_marks_no_focus_orphan() {
1588 let _ = frust_core::take_focus_orphaned();
1589 let mut counter = 0u64;
1590 let prev: Vec<AnyView<()>> = vec![leaf_any(10.0, 10.0), leaf_any(10.0, 10.0)];
1591 let mut pods: Vec<ChildPod> = prev
1592 .iter()
1593 .map(|v| build_child(v, &mut ctx(&mut counter)))
1594 .collect();
1595 pods[1].set_focused(true);
1596
1597 let next: Vec<AnyView<()>> = vec![leaf_any(20.0, 20.0), leaf_any(20.0, 20.0)];
1598 rebuild_children(
1599 &prev,
1600 &next,
1601 &mut pods,
1602 &mut ctx(&mut counter),
1603 |v: &AnyView<()>| v,
1604 |_: &AnyView<()>| None::<ChildKey>,
1605 );
1606 assert!(pods[1].is_focused(), "the focus path survives intact");
1607 assert!(
1608 !frust_core::take_focus_orphaned(),
1609 "an unchanged child must not orphan the focus session"
1610 );
1611 }
1612}
1613
1614/// Focus-path routing tests for [`route_event`]/[`route_event_single`]: Key/IME
1615/// events reach only the focused child (including through a nested container),
1616/// blur-on-outside-tap breaks the chain, a second focus request moves focus,
1617/// `ApplyEditingState` routes to the focused child, a published IME surface
1618/// bubbles up, and the capture and focus paths stay independent.
1619#[cfg(test)]
1620mod focus_tests {
1621 use super::*;
1622 use frust_core::{
1623 BoxConstraints, EditingState, EventCtx, ImeEvent, ImeState, Key, KeyEvent, LayoutCtx,
1624 Modifiers, PaintCtx, PaintScene,
1625 };
1626 use kurbo::{Point, Rect, Size};
1627
1628 /// A leaf that: on a pointer `Down` optionally requests focus and records
1629 /// `id + 100`; on a `Move` (capture path) records `id + 200`; on a Key/IME
1630 /// event records `id` and optionally publishes an IME surface. All state is a
1631 /// shared `Vec<u32>` so tests can assert *which* leaf saw *what*.
1632 struct KeyLeaf {
1633 id: u32,
1634 takes_focus: bool,
1635 publish_ime: bool,
1636 }
1637
1638 impl Widget for KeyLeaf {
1639 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1640 bc.max()
1641 }
1642 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1643 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1644 match event {
1645 InputEvent::Pointer(p) => match p.phase {
1646 PointerPhase::Down => {
1647 if self.takes_focus {
1648 ctx.request_focus();
1649 }
1650 ctx.state_mut::<Vec<u32>>().push(self.id + 100);
1651 EventResult::Handled
1652 }
1653 PointerPhase::Move => {
1654 ctx.state_mut::<Vec<u32>>().push(self.id + 200);
1655 EventResult::Handled
1656 }
1657 _ => EventResult::Handled,
1658 },
1659 InputEvent::Key(_) | InputEvent::Ime(_) => {
1660 ctx.state_mut::<Vec<u32>>().push(self.id);
1661 if self.publish_ime {
1662 ctx.publish_ime_state(ImeState {
1663 active: true,
1664 editing: EditingState {
1665 text: format!("leaf{}", self.id),
1666 selection_base: 1,
1667 selection_extent: 1,
1668 composing_base: -1,
1669 composing_extent: -1,
1670 },
1671 caret: Some(Rect::new(0.0, 0.0, 1.0, 10.0)),
1672 content_type: Default::default(),
1673 suppress_soft_keyboard: false,
1674 });
1675 }
1676 EventResult::Handled
1677 }
1678 _ => EventResult::Ignored,
1679 }
1680 }
1681 }
1682
1683 /// A minimal multi-child container: stacks its children vertically (each 10
1684 /// tall at the given width) and routes events through [`route_event`] — the
1685 /// same helper the real `Flex`/`Stack` use — so focus routing is exercised
1686 /// through a nested container.
1687 struct Nest {
1688 children: Vec<ChildPod>,
1689 }
1690
1691 impl Widget for Nest {
1692 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1693 let w = bc.max().width;
1694 for (i, pod) in self.children.iter_mut().enumerate() {
1695 pod.set_origin(Point::new(0.0, i as f64 * 10.0));
1696 pod.layout_child(ctx, &BoxConstraints::tight(Size::new(w, 10.0)));
1697 }
1698 Size::new(w, self.children.len() as f64 * 10.0)
1699 }
1700 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1701 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1702 route_event(&mut self.children, ctx, event)
1703 }
1704 }
1705
1706 fn leaf_pod(id: u32, takes_focus: bool, publish_ime: bool) -> ChildPod {
1707 ChildPod::new(Box::new(KeyLeaf {
1708 id,
1709 takes_focus,
1710 publish_ime,
1711 }))
1712 }
1713
1714 /// Lay out a slice of pods vertically (10 tall each at width 100) so hit
1715 /// testing distinguishes them: child `i` occupies `y ∈ [i*10, i*10+10)`.
1716 fn lay_out_vertically(pods: &mut [ChildPod]) {
1717 let mut lctx = LayoutCtx::new();
1718 for (i, pod) in pods.iter_mut().enumerate() {
1719 pod.set_origin(Point::new(0.0, i as f64 * 10.0));
1720 pod.layout_child(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 10.0)));
1721 }
1722 }
1723
1724 fn down(y: f64) -> InputEvent {
1725 InputEvent::Pointer(PointerEvent {
1726 phase: PointerPhase::Down,
1727 position: Point::new(5.0, y),
1728 button: PointerButton::Primary,
1729 })
1730 }
1731
1732 fn mv(y: f64) -> InputEvent {
1733 InputEvent::Pointer(PointerEvent {
1734 phase: PointerPhase::Move,
1735 position: Point::new(5.0, y),
1736 button: PointerButton::Primary,
1737 })
1738 }
1739
1740 fn key() -> InputEvent {
1741 InputEvent::Key(KeyEvent {
1742 key: Key::Character("a".to_string()),
1743 modifiers: Modifiers::default(),
1744 repeat: false,
1745 })
1746 }
1747
1748 fn ime_apply() -> InputEvent {
1749 InputEvent::Ime(ImeEvent::ApplyEditingState(EditingState {
1750 text: "sync".to_string(),
1751 selection_base: 4,
1752 selection_extent: 4,
1753 composing_base: -1,
1754 composing_extent: -1,
1755 }))
1756 }
1757
1758 // `state` must be a concrete `&mut Vec<u32>`: it is erased to `&mut dyn Any`
1759 // and the leaves recover it with `state_mut::<Vec<u32>>()`, so a slice would
1760 // fail the downcast.
1761 #[allow(clippy::ptr_arg)]
1762 fn route(children: &mut [ChildPod], state: &mut Vec<u32>, event: &InputEvent) -> EventResult {
1763 let mut ctx = EventCtx::new(state, Point::ZERO, Size::new(100.0, 100.0));
1764 route_event(children, &mut ctx, event)
1765 }
1766
1767 #[test]
1768 fn key_routes_only_to_focused_child() {
1769 let mut children = vec![leaf_pod(1, true, false), leaf_pod(2, true, false)];
1770 lay_out_vertically(&mut children);
1771 let mut state = Vec::new();
1772
1773 // Tap child 2 (y in [10,20)) → it requests focus.
1774 route(&mut children, &mut state, &down(15.0));
1775 assert!(children[1].is_focused());
1776 assert!(!children[0].is_focused());
1777 state.clear();
1778
1779 // A Key event reaches ONLY the focused child (id 2), never child 1.
1780 route(&mut children, &mut state, &key());
1781 assert_eq!(state, vec![2]);
1782 }
1783
1784 #[test]
1785 fn key_reaches_focused_leaf_through_nested_container() {
1786 // Outer container holds one Nest; the Nest holds two leaves.
1787 let inner = vec![leaf_pod(1, true, false), leaf_pod(2, true, false)];
1788 let mut nest = Nest { children: inner };
1789 // Lay out the nest so its inner children get real geometry.
1790 {
1791 let mut lctx = LayoutCtx::new();
1792 nest.layout(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 20.0)));
1793 }
1794 let mut outer = vec![ChildPod::new(Box::new(nest))];
1795 outer[0].set_origin(Point::ZERO);
1796 {
1797 let mut lctx = LayoutCtx::new();
1798 outer[0].layout_child(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 20.0)));
1799 }
1800 let mut state = Vec::new();
1801
1802 // Tap the second inner leaf (y in [10,20)) → focus chain nest→leaf2.
1803 route(&mut outer, &mut state, &down(15.0));
1804 assert!(outer[0].is_focused(), "the nest records the focus path");
1805 state.clear();
1806
1807 // Key routes outer→nest→leaf2 only.
1808 route(&mut outer, &mut state, &key());
1809 assert_eq!(state, vec![2]);
1810 }
1811
1812 #[test]
1813 fn blur_on_outside_tap_clears_focus_chain() {
1814 // Child 1 takes focus; child 2 does NOT (a non-editable widget).
1815 let mut children = vec![leaf_pod(1, true, false), leaf_pod(2, false, false)];
1816 lay_out_vertically(&mut children);
1817 let mut state = Vec::new();
1818
1819 route(&mut children, &mut state, &down(5.0)); // focus child 1
1820 assert!(children[0].is_focused());
1821
1822 // Tap child 2 (does not take focus) → blur clears the focus chain.
1823 route(&mut children, &mut state, &down(15.0));
1824 assert!(
1825 !children[0].is_focused(),
1826 "outside tap blurs the focused child"
1827 );
1828 assert!(!children[1].is_focused());
1829 state.clear();
1830
1831 // A Key event now reaches nobody.
1832 let result = route(&mut children, &mut state, &key());
1833 assert_eq!(result, EventResult::Ignored);
1834 assert!(state.is_empty());
1835 }
1836
1837 #[test]
1838 fn tap_inside_focused_widget_keeps_focus() {
1839 // A focused widget re-tapped keeps focus (Down inside focused widget).
1840 let mut children = vec![leaf_pod(1, true, false), leaf_pod(2, false, false)];
1841 lay_out_vertically(&mut children);
1842 let mut state = Vec::new();
1843
1844 route(&mut children, &mut state, &down(5.0)); // focus child 1
1845 route(&mut children, &mut state, &down(5.0)); // tap child 1 again
1846 assert!(
1847 children[0].is_focused(),
1848 "a tap inside the focused widget keeps focus"
1849 );
1850 state.clear();
1851 route(&mut children, &mut state, &key());
1852 assert_eq!(state, vec![1]);
1853 }
1854
1855 #[test]
1856 fn second_focus_request_moves_focus() {
1857 let mut children = vec![leaf_pod(1, true, false), leaf_pod(2, true, false)];
1858 lay_out_vertically(&mut children);
1859 let mut state = Vec::new();
1860
1861 route(&mut children, &mut state, &down(5.0)); // focus child 1
1862 assert!(children[0].is_focused());
1863 route(&mut children, &mut state, &down(15.0)); // focus child 2
1864 assert!(children[1].is_focused());
1865 assert!(!children[0].is_focused(), "focus moved off child 1");
1866 state.clear();
1867
1868 route(&mut children, &mut state, &key());
1869 assert_eq!(
1870 state,
1871 vec![2],
1872 "key now reaches only the newly focused child"
1873 );
1874 }
1875
1876 #[test]
1877 fn ime_apply_routes_to_focused_child() {
1878 let mut children = vec![leaf_pod(1, true, false), leaf_pod(2, true, false)];
1879 lay_out_vertically(&mut children);
1880 let mut state = Vec::new();
1881
1882 route(&mut children, &mut state, &down(15.0)); // focus child 2
1883 state.clear();
1884 route(&mut children, &mut state, &ime_apply());
1885 assert_eq!(state, vec![2]);
1886 }
1887
1888 #[test]
1889 fn capture_and_focus_paths_are_independent() {
1890 // Child 1 holds the capture (active) path; child 2 holds the focus path.
1891 let mut children = vec![leaf_pod(1, false, false), leaf_pod(2, false, false)];
1892 lay_out_vertically(&mut children);
1893 children[0].set_active(true);
1894 children[1].set_focused(true);
1895 let mut state = Vec::new();
1896
1897 // A Move routes down the capture path → child 1 only (id+200).
1898 route(&mut children, &mut state, &mv(999.0));
1899 assert_eq!(state, vec![201]);
1900 state.clear();
1901
1902 // A Key routes down the focus path → child 2 only (id).
1903 route(&mut children, &mut state, &key());
1904 assert_eq!(state, vec![2]);
1905
1906 // Both paths survive intact.
1907 assert!(children[0].is_active());
1908 assert!(children[1].is_focused());
1909 }
1910}