Skip to main content

incular_runtime/
route_outlet.rs

1//! Hosts one navigator's routes as mounted, identity-tagged content.
2//!
3//! [`RouteOutlet`] is the production seam between a navigation stack and a
4//! window tree: it composes every mounted route's content per an explicit
5//! [`OutletPlacement`] policy, drives focus save/restore/forget and route
6//! task bindings from live navigator state, and derives element ownership
7//! from the mounted tags — applications never maintain element-id oracles.
8//!
9//! Drive contract: mount [`RouteOutlet::widget`] in the window tree,
10//! attach once with [`RouteOutlet::attach`], then drive every cycle with
11//! the single [`RouteOutlet::present_frame`] operation — rebuild, frame,
12//! and reconcile in enforced order, with tree-wide validation before and
13//! after the frame. Covered routes without retention unmount (disposal);
14//! retained ones stay mounted inert while invisible; only permanent
15//! removal ends lifetimes.
16//!
17//! Frame contract: a frame runs build, layout, composite, semantics, then
18//! paint, so restoration — which reconciles after the frame returns —
19//! cannot make that same frame's paint or semantics. The restore applies
20//! to the focus slot synchronously and flags follow-up work through the
21//! existing scheduler (`set_focus` marks another frame); the next frame
22//! then finalizes styling, semantics, and keyboard dispatch from restored
23//! focus. No unconditional extra frame runs: follow-up is flagged when
24//! reconciliation changed focus, or when the presented output is older
25//! than live navigation (mid-build navigation flags exactly once per
26//! revision, nested staleness wakes the root driver, and quiet frames
27//! flag nothing). Errors schedule no follow-up at all, so rejected
28//! stacks cannot spin a retry loop. Deferred content converges through
29//! its own invalidation afterwards.
30//!
31//! Driving states (derived from the retained fields, not tracked
32//! separately — `pending`, `consumed` vs live revision, `needs_frame`,
33//! and the per-revision schedule memo):
34//!
35//! | Situation | pending | consumed | needs_frame | scheduled | exit |
36//! | Unattached, empty | none | none | false | — | navigate → stale |
37//! | Unattached, routes live | per capture | older/none | true | on success | attach → attached |
38//! | Attached, idle | none | current | false | — | navigate → pending |
39//! | Attempt pending | some | just composed | false, true after mid-frame nav | — | commit → consumed; stale → follow-up |
40//! | Failed (error/panic) | kept | older | true | reset, re-evaluates | retry → pending/consumed |
41//! | Successful-but-stale | recaptured/none | older | true | once per rev | follow-up → consumed |
42//! | Detached | kept (own) | frozen | own tree only | own claims | reattach or drive standalone |
43
44use std::{
45    cell::{Cell, RefCell},
46    collections::{HashMap, HashSet},
47    rc::{Rc, Weak},
48    sync::atomic::{AtomicU64, Ordering},
49};
50
51use incular_config::Constraints;
52use incular_navigation::{ModalBarrier, ModalRoute, Navigator, Route, RouteId, RoutePresentation};
53use incular_rendering::DisplayList;
54use incular_widgets::{
55    AnimatedModalBarrier, DoubleAnimation, ExcludeFocusTraversal, IgnorePointer, LayoutBuilder,
56    SizedBox, Stack, Visibility, Widget, always_dismissed,
57    internal::{ElementId, Key, TreeError, WidgetTree},
58};
59
60use super::{
61    frame::{FrameStats, Runtime},
62    tasks::TaskScope,
63};
64use crate::{RouteTaskBinding, route_focus::RouteFocusState};
65
66/// Sequence numbering outlet key namespaces so sibling outlets sharing one
67/// tree never tag two routes alike.
68static OUTLET_SEQUENCE: AtomicU64 = AtomicU64::new(1);
69
70/// How the outlet presents one route, derived from its [`RoutePresentation`].
71///
72/// Modes the outlet cannot honor are explicit variants, never silent
73/// behavior: overlay entries are portal-managed and never outlet-mounted.
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub enum OutletPlacement {
76    /// Mounted stack content. `visible` paints, hits, and exposes
77    /// semantics; invisible content stays mounted inert while the route
78    /// retains state. `barrier` veils content below while mounted.
79    Stacked {
80        visible: bool,
81        barrier: Option<ModalBarrier>,
82    },
83    /// Portal-managed overlay entries: host them in an `OverlayPortal`
84    /// separately instead of mounting them here.
85    ExternalOverlay,
86}
87
88impl OutletPlacement {
89    /// Classifies `presentation` given whether any opaque route above
90    /// covers it. Only opaque pages occlude, so translucent veils dim
91    /// painted content rather than blackness.
92    pub fn of(presentation: &RoutePresentation, visible: bool) -> Self {
93        if presentation.is_overlay() {
94            return Self::ExternalOverlay;
95        }
96        Self::Stacked {
97            visible,
98            barrier: presentation.barrier().cloned(),
99        }
100    }
101}
102
103/// The navigation state one frame attempt builds content from: the
104/// authoritative stack revision plus the ordered route identities the
105/// frame composes. Immutable once captured; refreshed (never re-read for
106/// focus) at every build boundary so retries describe the content they
107/// actually present. Presentation is deliberately not snapshotted: it is
108/// fixed for a route identity, so revision plus identities is
109/// authoritative — no boolean approximation.
110#[derive(Clone, Debug, PartialEq, Eq)]
111struct FrameAttempt {
112    revision: u64,
113    active: Option<RouteId>,
114    members: Vec<RouteId>,
115}
116
117impl FrameAttempt {
118    fn capture(navigator: &Navigator) -> Self {
119        Self::of(navigator.revision(), &navigator.routes())
120    }
121
122    /// Snapshots exactly these routes at this revision: [`RouteOutlet::widget`]
123    /// builds the attempt from the same route list it composes children
124    /// from, so acquisition and composition stay together.
125    fn of(revision: u64, routes: &[Route]) -> Self {
126        Self {
127            revision,
128            active: routes.last().map(|route| route.id),
129            members: routes.iter().map(|route| route.id).collect(),
130        }
131    }
132
133    /// Whether the live navigator still matches this attempt closely
134    /// enough to commit. Same revision means nothing moved. Otherwise the
135    /// member set must match under the same active route: order-only
136    /// churn (a lower-route reorder beneath an unchanged top) and
137    /// same-identity child replacement keep the per-route bookkeeping
138    /// valid, while membership changes — pushes, pops, removals —
139    /// abandon. Push-then-pop back to the original top with no pending
140    /// transition never reaches the commit; with one in flight the ids
141    /// differ and the capture abandons cleanly.
142    fn covers(&self, navigator: &Navigator) -> bool {
143        if navigator.revision() == self.revision {
144            return true;
145        }
146        let current = Self::capture(navigator);
147        current.active == self.active
148            && current.members.len() == self.members.len()
149            && self.members.iter().all(|id| current.members.contains(id))
150    }
151}
152
153/// One uncommitted frame transition, tracking four independent concerns:
154/// - the outgoing focus save (`previous`/`saved`), which survives retries
155///   untouched — rebuilding re-records consumption without re-reading;
156/// - the navigation snapshot for this attempt (`attempt`), adopted from
157///   what composition actually consumed (see `consumed`);
158/// - newer-work detection, via the consumed revision versus live state
159///   (see [`RouteOutlet::needs_frame`]);
160/// - lifetime eligibility, checked at commit (incoming) and at restore
161///   (records never restore for removed routes).
162///
163/// The save lives here — not in the records — until the frame presenting
164/// it commits, so failed attempts and navigation mid-frame can never
165/// disturb committed state.
166#[derive(Clone, Debug, PartialEq, Eq)]
167struct PendingTransition {
168    previous: Option<RouteId>,
169    incoming: Option<RouteId>,
170    saved: Option<ElementId>,
171    attempt: FrameAttempt,
172}
173
174/// Identity of one [`RouteOutlet`], for actionable error data and host
175/// bookkeeping. Assigned once per outlet; sibling outlets sharing one
176/// tree never collide.
177#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
178pub struct OutletId(u64);
179
180impl std::fmt::Display for OutletId {
181    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        write!(formatter, "route-outlet-{}", self.0)
183    }
184}
185
186/// A route outlet refusing to present.
187///
188/// Timing differs per variant, and the docs say which: pre-frame
189/// rejections change nothing mounted or committed, while the post-frame
190/// validation can fire after the tree mounted the frame's content — in
191/// that case integration state (records, bindings, tags, saves) is still
192/// untouched, but the tree holds the last frame's stack output. Hosts fix
193/// the stack (portal-mount the listed routes, or keep them out of
194/// outlet-driven navigators) instead of debugging silently omitted
195/// content.
196#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
197pub enum OutletError {
198    /// One or more routes use portal-managed overlay presentations, which
199    /// the outlet never mounts. Carries the offending outlet alongside
200    /// the route ids, so nested rejections point at the responsible
201    /// navigator instead of the driving root.
202    #[error(
203        "{outlet} cannot present portal-managed overlay routes: {routes:?} \
204         (host overlay entries in an OverlayPortal separately)"
205    )]
206    UnsupportedPresentation {
207        outlet: OutletId,
208        routes: Vec<RouteId>,
209    },
210    /// Another live runtime already drives this outlet: the outlet is
211    /// attached to (or last presented by) `owner`, so presenting through
212    /// `attempted` would split one lifecycle across two frame loops.
213    /// Detach or drop the first runtime to release the claim — teardown
214    /// releases it automatically — then drive here.
215    #[error(
216        "{outlet} is already driven by runtime-{owner}: refusing runtime-{attempted} \
217         (one lifecycle per runtime; release the first claim before driving here)"
218    )]
219    DriverConflict {
220        outlet: OutletId,
221        owner: u64,
222        attempted: u64,
223    },
224    /// This outlet nests under a live parent: drive the root instead.
225    /// Separate presents would drive the child twice per frame (once
226    /// directly, once through the cascade) and corrupt the pending
227    /// capture. Detach first to drive it standalone.
228    #[error(
229        "{outlet} nests under {parent}: drive the root instead of presenting \
230         the child separately (detach first to drive it standalone)"
231    )]
232    SeparateDrive { outlet: OutletId, parent: OutletId },
233    /// The underlying rebuild or frame failed.
234    #[error("route outlet frame failed: {0}")]
235    Frame(#[from] TreeError),
236}
237
238/// Owns cleanup for one driven attempt. Armed before the fallible
239/// section; success disarms after flagging, while every early return —
240/// and panic unwind — drops it armed and resets the schedule memos.
241/// What survives a failed attempt, by design: the outgoing save and the
242/// pending capture (untouched — retry needs them), the published
243/// snapshot (no promotion ran), the driver claim (predates the attempt),
244/// and live navigation itself. Only the schedule memo resets, so the
245/// next success re-evaluates outstanding work. Panics resume unwinding
246/// immediately after the reset: integration invariants are restored,
247/// never repaired, and application panics never become successful
248/// frames.
249struct AttemptGuard<'a> {
250    outlet: &'a Rc<RefCell<RouteOutlet>>,
251    armed: bool,
252}
253
254impl<'a> AttemptGuard<'a> {
255    fn arm(outlet: &'a Rc<RefCell<RouteOutlet>>) -> Self {
256        Self {
257            outlet,
258            armed: true,
259        }
260    }
261
262    fn disarm(&mut self) {
263        self.armed = false;
264    }
265}
266
267impl Drop for AttemptGuard<'_> {
268    fn drop(&mut self) {
269        if self.armed {
270            self.outlet.borrow().reset_signaled_tree();
271        }
272    }
273}
274
275/// Why a nested outlet attachment was rejected.
276///
277/// Rejection happens before any mutation, so a failed attach leaves both
278/// outlets exactly as they were. The topology is a forest by design —
279/// like back dispatch it must terminate, but unlike back dispatch (which
280/// follows a single active chain and can share children) cascade driving
281/// visits every registered child, so one child under two parents would
282/// drive twice per frame.
283#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
284pub enum OutletAttachError {
285    /// An outlet cannot nest inside itself.
286    #[error("a route outlet cannot nest inside itself")]
287    SelfAttachment,
288    /// The child already reaches the parent through nested attachments,
289    /// so the edge would close a directed cycle and cascade driving
290    /// could recurse through it forever.
291    #[error(
292        "attaching this outlet would cycle cascade driving: \
293         it already reaches its parent"
294    )]
295    Cycle,
296    /// The child is already attached to a different live parent. Detach
297    /// it explicitly first: cascade driving follows registration, so a
298    /// second parent would drive the same outlet twice per frame.
299    #[error(
300        "this outlet is already attached to a different parent: \
301         detach it explicitly before reattaching"
302    )]
303    AlreadyAttached,
304}
305
306/// Mounts one [`Navigator`] as presentation-aware stack content.
307///
308/// The outlet owns the integration state the navigator cannot: per-route
309/// mount tags feeding the focus ownership oracle, a [`RouteFocusState`]
310/// driven across transitions, and one [`RouteTaskBinding`] per live
311/// route (lifetime-owned, including unmounted-but-alive routes). Use one
312/// outlet per navigator; nested navigators get nested
313/// outlets and stay isolated through disjoint tags and states.
314///
315/// Dropping the outlet releases the integration: focus records, bindings,
316/// and tags go with it (bound scopes then follow ordinary [`TaskScope`]
317/// ownership, detached from later removals).
318///
319/// State inventory (owner → invariant): `navigator` (cloned handle —
320/// identity and lifetimes live in navigation); `focus`, `bindings`,
321/// `tags` (outlet — pruned to live routes on every reconcile);
322/// `nested`/`parent` (attachment graph — weak both ways, single-parent,
323/// acyclic); `driver` (driving runtime — claimed on drive, released on
324/// detach or runtime drop); `task_parent` (host scope — never mutated);
325/// `presented`/`pending` (transition bookkeeping — advance only on
326/// commit); `epoch` (attempt context — one per driven frame);
327/// `composing`/`consumed` (composition record — receipted mounting
328/// builders only, promoted on success only); `signaled`
329/// (schedule memo — edge-triggered per revision); `namespace`/`root_key`
330/// (immutable
331/// construction identity); `tag_sequence` (monotonic, never reused);
332/// `revision` (invalidation generation — doubles as the drive counter);
333/// `mount` (attached element — cleared when it vanishes).
334pub struct RouteOutlet {
335    navigator: Navigator,
336    focus: RouteFocusState,
337    bindings: HashMap<RouteId, RouteTaskBinding>,
338    tags: Rc<RefCell<HashMap<RouteId, Key>>>,
339    /// Directly nested outlets, weakest first. Elements inside a nested
340    /// outlet belong to it even when an outer tag sits above them;
341    /// transitivity follows containment, so only direct children register.
342    /// Weak handles release automatically when a nested outlet drops, and
343    /// dead entries prune on every present, so repeated replacement never
344    /// grows metadata. Registration is what cascade driving follows: a
345    /// detached outlet receives no frame work even while retained.
346    nested: Rc<RefCell<Vec<Weak<RefCell<RouteOutlet>>>>>,
347    /// Whether route content is wrapped in a [`ModalRoute`] scope.
348    modal_route_scopes: bool,
349    /// This outlet's parent, if attached. Single-parent by design (see
350    /// [`OutletAttachError`]): set on attach, cleared on detach or when
351    /// the parent drops.
352    parent: Rc<RefCell<Option<Weak<RefCell<RouteOutlet>>>>>,
353    /// The runtime driving this outlet, with its liveness token. Claimed
354    /// on attach and on every driven present (roots and cascade children
355    /// alike); released on detach and automatically when the runtime
356    /// drops. No global registry: ownership lives in this claim plus the
357    /// runtime's own builder registration.
358    driver: RefCell<Option<(u64, Weak<u64>)>>,
359    task_parent: TaskScope,
360    /// Last successfully presented active route. Transitions capture
361    /// against this and advance it only on commit — never on failure.
362    presented: Option<RouteId>,
363    /// Uncommitted capture: `Some` while a frame transition is in flight.
364    pending: Option<PendingTransition>,
365    /// Frame attempt counter: bumped by every [`Self::begin_frame`].
366    /// Necessary context for [`Self::composing`], not proof of mounting:
367    /// only a receipt stamped by a mounting builder during the current
368    /// attempt authorizes adoption.
369    epoch: Cell<u64>,
370    /// Publication slot for in-flight composition: the mounting builders
371    /// write a receipt (attempt plus snapshot) here on every execution
372    /// (see [`Self::compose_and_record`]). Never read directly for
373    /// decisions — reconciliation promotes it (see `consumed`) only on
374    /// the success path for the current attempt, so failed composition
375    /// publishes nothing and discarded descriptors authorize nothing.
376    composing: RefCell<Option<(u64, FrameAttempt)>>,
377    /// Last composition a frame carried through reconciliation for this
378    /// outlet. Comparing it against live state answers whether newer
379    /// navigation still requires a frame (see [`Self::needs_frame`])
380    /// independently of the pending save above. Each outlet promotes its
381    /// own: nested slot builders promote when they execute and reconcile,
382    /// skipped builders keep the previous publication.
383    consumed: Option<FrameAttempt>,
384    /// Live revision last scheduled for. Follow-up scheduling is
385    /// edge-triggered per outlet: a stale revision flags exactly once no
386    /// matter how many frames observe it, so unconsumable staleness
387    /// (content no present can compose) idles instead of spinning. Reset
388    /// whenever a present fails: a failed frame consumes nothing, so the
389    /// next success must re-evaluate from scratch instead of inheriting
390    /// the failure's suppression.
391    signaled: Cell<u64>,
392    namespace: u64,
393    /// Monotonic tag counter. Freed tags are never reassigned (unlike the
394    /// map length, which shrinks on removal), so element identity can never
395    /// alias across removals. Interior so [`Self::widget`] shares by ref.
396    tag_sequence: Cell<u64>,
397    /// Mount tag on the outlet root, used to locate it for [`Self::attach`].
398    root_key: Key,
399    /// Invalidation generation bumped once per driven frame; nested
400    /// stateful slots observe it to rebuild outlet content (builder-owned
401    /// children survive ancestor rebuilds, so nested state and focus
402    /// persist across outer frames). Doubles as the drive counter read by
403    /// [`Self::frame_drive_count`]: one begin per present per outlet.
404    revision: Rc<Cell<u64>>,
405    /// Route animations (Flutter `TransitionRoute`): the routes the last
406    /// frame presented, the routes whose animations already started, and
407    /// popped or replaced routes still presented while they animate out.
408    animated: RefCell<RouteAnimations>,
409    /// Attached mount element, if [`Self::attach`] located the outlet root.
410    /// Cleared when the mount disappears so later frames degrade to
411    /// frame-plus-reconcile instead of erroring forever.
412    mount: Cell<Option<ElementId>>,
413}
414
415impl RouteOutlet {
416    /// Hosts `navigator`, parenting route task scopes under `task_parent`
417    /// (typically the window scope, so close still cancels through existing
418    /// ownership).
419    pub fn new(navigator: &Navigator, task_parent: &TaskScope) -> Self {
420        let tags: Rc<RefCell<HashMap<RouteId, Key>>> = Rc::default();
421        let tags_for_oracle = tags.clone();
422        let nested: Rc<RefCell<Vec<Weak<RefCell<RouteOutlet>>>>> = Rc::default();
423        let nested_for_oracle = nested.clone();
424        let focus = RouteFocusState::new(move |tree: &WidgetTree, id: ElementId| {
425            // Ancestor chain of the element, self included. Nested outlets
426            // win first: anything at or below a nested outlet root belongs
427            // to that outlet, even with an outer tag above it. Remaining
428            // attribution is by ancestry — sibling route subtrees never
429            // contain each other, so any matched own tag is the nearest.
430            // Scans stay bounded: one ancestor walk plus one reverse lookup
431            // per tag, on transitions and vacant retries only — never a
432            // per-route sweep. Dead handles simply miss.
433            let mut chain = Vec::new();
434            let mut cursor = Some(id);
435            while let Some(current) = cursor {
436                chain.push(current);
437                cursor = tree.parent(current);
438            }
439            for nested in nested_for_oracle.borrow().iter() {
440                let Some(nested) = nested.upgrade() else {
441                    continue;
442                };
443                let nested = nested.borrow();
444                if let Some(root) = tree.element_with_key(&nested.root_key)
445                    && (root == id || chain.contains(&root))
446                {
447                    return None;
448                }
449            }
450            tags_for_oracle.borrow().iter().find_map(|(route, key)| {
451                tree.element_with_key(key)
452                    .is_some_and(|root| chain.contains(&root))
453                    .then_some(*route)
454            })
455        });
456        let namespace = OUTLET_SEQUENCE
457            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
458            .expect("route outlet identity space exhausted");
459        Self {
460            navigator: navigator.clone(),
461            focus,
462            bindings: HashMap::new(),
463            tags,
464            nested,
465            modal_route_scopes: false,
466            parent: Rc::default(),
467            driver: RefCell::default(),
468            task_parent: task_parent.clone(),
469            presented: None,
470            pending: None,
471            epoch: Cell::new(0),
472            composing: RefCell::default(),
473            consumed: None,
474            signaled: Cell::new(0),
475            animated: RefCell::default(),
476            namespace,
477            // NOTE: no separate drive counter — `revision` below advances
478            // once per driven frame and serves as the drive count.
479            tag_sequence: Cell::new(0),
480            root_key: Key::String(format!("route-outlet-root-{namespace}")),
481            revision: Rc::new(Cell::new(0)),
482            mount: Cell::new(None),
483        }
484    }
485
486    /// Registers a directly nested outlet whose content mounts inside this
487    /// outlet's routes. Nested elements resolve to the nested outlet, so
488    /// inner navigation can neither overwrite outer saved focus nor be
489    /// restored over by it. Sibling outlets sharing only an ancestor need
490    /// nothing: disjoint subtrees never attribute across. Transitivity
491    /// follows containment — register each outlet with its direct parent.
492    ///
493    /// Topology is enforced before any mutation, borrowing the
494    /// back-dispatch lesson without coupling to it: self-attachment and
495    /// cycles are rejected (cascade driving must terminate), and each
496    /// outlet has at most one live parent (cascade driving visits every
497    /// registered child, so a shared child would drive twice per frame).
498    /// Re-registering with the same parent is an idempotent no-op;
499    /// moving parents requires [`Self::detach_nested`] first.
500    /// Registration holds weakly: dropping the nested outlet releases it,
501    /// and dead entries prune on every present, so repeated replacement
502    /// never grows metadata.
503    pub fn attach_nested(
504        outlet: &Rc<RefCell<Self>>,
505        nested: &Rc<RefCell<Self>>,
506    ) -> Result<(), OutletAttachError> {
507        if Rc::ptr_eq(outlet, nested) {
508            return Err(OutletAttachError::SelfAttachment);
509        }
510        if nested.borrow().reaches(outlet) {
511            return Err(OutletAttachError::Cycle);
512        }
513        {
514            let child = nested.borrow();
515            let mut parent = child.parent.borrow_mut();
516            if let Some(current) = parent.as_ref().and_then(Weak::upgrade) {
517                if Rc::ptr_eq(&current, outlet) {
518                    return Ok(());
519                }
520                return Err(OutletAttachError::AlreadyAttached);
521            }
522            // A dead parent link is just a leftover: detach cleared the
523            // registration but the child outlived this assignment.
524            *parent = Some(Rc::downgrade(outlet));
525        }
526        let outlet = outlet.borrow_mut();
527        let mut attached = outlet.nested.borrow_mut();
528        attached.retain(|existing| {
529            existing
530                .upgrade()
531                .is_some_and(|live| !Rc::ptr_eq(&live, nested))
532        });
533        attached.push(Rc::downgrade(nested));
534        Ok(())
535    }
536
537    /// Detaches a nested outlet. The child keeps its own records, bindings,
538    /// and tags, but the parent's cascade no longer drives it: a detached
539    /// outlet receives no frame work even while application code retains
540    /// its handle. Detach also releases the child's driver claim, so
541    /// ownership transfers cleanly — reattaching elsewhere (or driving it
542    /// standalone, including under another runtime) claims anew.
543    /// Detaching a non-child is a no-op.
544    pub fn detach_nested(outlet: &Rc<RefCell<Self>>, nested: &Rc<RefCell<Self>>) {
545        {
546            let child = nested.borrow_mut();
547            let mut parent = child.parent.borrow_mut();
548            let mine = parent
549                .as_ref()
550                .and_then(Weak::upgrade)
551                .is_some_and(|current| Rc::ptr_eq(&current, outlet));
552            if mine {
553                parent.take();
554                child.driver.borrow_mut().take();
555            }
556        }
557        {
558            let outlet = outlet.borrow_mut();
559            outlet.nested.borrow_mut().retain(|existing| {
560                existing
561                    .upgrade()
562                    .is_some_and(|live| !Rc::ptr_eq(&live, nested))
563            });
564        }
565    }
566
567    /// Whether `target` is reachable from this outlet by following nested
568    /// attachments. Dead entries are skipped, never traversed.
569    fn reaches(&self, target: &Rc<RefCell<Self>>) -> bool {
570        let mut visited: Vec<*const RefCell<Self>> = Vec::new();
571        let mut stack: Vec<Rc<RefCell<Self>>> = self.live_nested();
572        // The outlet itself counts: self-attachment is checked separately
573        // for its error, but a zero-length path must not read as a cycle.
574        while let Some(node) = stack.pop() {
575            if Rc::ptr_eq(&node, target) {
576                return true;
577            }
578            let address = Rc::as_ptr(&node);
579            if visited.contains(&address) {
580                continue;
581            }
582            visited.push(address);
583            stack.extend(node.borrow().live_nested());
584        }
585        false
586    }
587
588    /// Frames driven through this outlet, cascade included. Each
589    /// [`Self::present_frame`] on the driving root advances every
590    /// participating outlet by exactly one. Reads the invalidation
591    /// generation, which advances once per driven frame and needs no
592    /// separate counter.
593    #[must_use]
594    pub fn frame_drive_count(&self) -> u64 {
595        self.revision.get()
596    }
597
598    /// Mounts a nested outlet's widget with the required builder identity:
599    /// a stable keyed boundary driven by the nested revision, so ancestor
600    /// rebuilds retain the slot instead of remounting it. Hosts compose
601    /// this inside outer route content instead of assembling stateful
602    /// builders by hand; no nested `attach` is needed on top. Presenting
603    /// the outer outlet drives the whole tree in one frame — nested
604    /// outlets never need their own `present_frame` calls.
605    pub fn nested_widget(nested: &Rc<RefCell<Self>>) -> Widget {
606        let (revision, namespace) = {
607            let nested = nested.borrow();
608            (nested.revision.clone(), nested.namespace)
609        };
610        let nested_for_build = Rc::clone(nested);
611        Widget::stateful_builder(revision, move |_| {
612            nested_for_build.borrow().compose_and_record()
613        })
614        .with_key(Key::String(format!("route-outlet-slot-{namespace}")))
615    }
616
617    /// Number of live nested attachments. Dead handles prune on every
618    /// present; useful for hosts composing nested outlets dynamically.
619    #[must_use]
620    pub fn attached_nested_count(&self) -> usize {
621        self.nested
622            .borrow()
623            .iter()
624            .filter(|weak| weak.upgrade().is_some())
625            .count()
626    }
627
628    /// Live nested outlets for cascade driving.
629    fn live_nested(&self) -> Vec<Rc<RefCell<Self>>> {
630        self.nested
631            .borrow()
632            .iter()
633            .filter_map(Weak::upgrade)
634            .collect()
635    }
636
637    /// The live parent, if this outlet is attached under one.
638    fn live_parent(&self) -> Option<Rc<RefCell<Self>>> {
639        self.parent.borrow().as_ref().and_then(Weak::upgrade)
640    }
641
642    /// The live driver claim's runtime id, if a live runtime owns it. A
643    /// dead token means teardown released the claim: treated as unowned.
644    fn live_driver(&self) -> Option<u64> {
645        self.driver
646            .borrow()
647            .as_ref()
648            .and_then(|(id, token)| token.upgrade().is_some().then_some(*id))
649    }
650
651    /// Claims this outlet for `runtime`, superseding a dead owner's
652    /// leftover. Callers verify conflicts first.
653    fn claim_driver(&self, runtime: &Runtime) {
654        *self.driver.borrow_mut() = Some((runtime.id(), runtime.driver_token()));
655    }
656
657    /// Rejects conflicting drivers for the whole tree before any mutation:
658    /// the root must not nest under a live parent (drive the root
659    /// instead), and every participant must be unclaimed or claimed by
660    /// `runtime` — a live foreign claim fails with both identities.
661    fn check_drivers(&self, runtime: &Runtime, is_root: bool) -> Result<(), OutletError> {
662        if is_root && let Some(parent) = self.live_parent() {
663            return Err(OutletError::SeparateDrive {
664                outlet: self.id(),
665                parent: parent.borrow().id(),
666            });
667        }
668        if let Some(owner) = self.live_driver()
669            && owner != runtime.id()
670        {
671            return Err(OutletError::DriverConflict {
672                outlet: self.id(),
673                owner,
674                attempted: runtime.id(),
675            });
676        }
677        for nested in self.live_nested() {
678            nested.borrow().check_drivers(runtime, false)?;
679        }
680        Ok(())
681    }
682
683    /// Claims the whole tree for `runtime` after [`Self::check_drivers`]
684    /// passes. Refreshes matching claims harmlessly.
685    fn claim_tree(&self, runtime: &Runtime) {
686        self.claim_driver(runtime);
687        for nested in self.live_nested() {
688            nested.borrow().claim_tree(runtime);
689        }
690    }
691
692    /// Returns the navigator this outlet hosts.
693    /// Publishes each route's [`ModalRoute`] to its content, so
694    /// `ModalRoute::of` works inside routes. Off by default: the scope is
695    /// an inherited (lazily built) node, so route content then validates
696    /// when it mounts rather than before the outlet commits a rebuild, and
697    /// a malformed route reports `InvalidGeneratedChild` instead of failing
698    /// the rebuild atomically.
699    pub fn set_modal_route_scopes(&mut self, enabled: bool) {
700        self.modal_route_scopes = enabled;
701    }
702
703    #[must_use]
704    pub fn modal_route_scopes(&self) -> bool {
705        self.modal_route_scopes
706    }
707
708    #[must_use]
709    pub fn navigator(&self) -> &Navigator {
710        &self.navigator
711    }
712
713    /// Returns this outlet's identity, for error data and host bookkeeping.
714    #[must_use]
715    pub fn id(&self) -> OutletId {
716        OutletId(self.namespace)
717    }
718
719    /// Whether navigation arrived that no composition consumed yet — for
720    /// this outlet or any nested outlet. True before the first composition
721    /// with a non-empty stack, and after navigation newer than the last
722    /// consumed snapshot (including navigation during a build, whose paint
723    /// lags one frame behind the bookkeeping). False once composition
724    /// consumes current state. Diagnostics and custom drivers only:
725    /// ordinary hosts must not poll this for mandatory work — the
726    /// scheduler flag from `present_frame` carries that. It does not
727    /// report failed frames (their error return does that).
728    #[must_use]
729    pub fn needs_frame(&self) -> bool {
730        let stale = match &self.consumed {
731            None => !self.navigator.routes().is_empty(),
732            Some(consumed) => consumed.revision != self.navigator.revision(),
733        };
734        stale
735            || !self.animated.borrow().exiting.is_empty()
736            || self
737                .live_nested()
738                .iter()
739                .any(|nested| nested.borrow().needs_frame())
740    }
741
742    /// Rejects this outlet's stack when it contains portal-managed overlay
743    /// routes, which the outlet never mounts. Pure check: no state changes
744    /// either way.
745    fn preflight(&self) -> Result<(), OutletError> {
746        let unsupported: Vec<RouteId> = self
747            .navigator
748            .routes()
749            .iter()
750            .filter(|route| route.presentation.is_overlay())
751            .map(|route| route.id)
752            .collect();
753        if unsupported.is_empty() {
754            Ok(())
755        } else {
756            Err(OutletError::UnsupportedPresentation {
757                outlet: self.id(),
758                routes: unsupported,
759            })
760        }
761    }
762
763    /// Validates the whole outlet tree top-down: this outlet, then every
764    /// nested outlet transitively. Pure check — no revisions, captures,
765    /// rebuilds, or frames happen on either outcome — so
766    /// [`Self::present_frame`] runs it before driving anything and again
767    /// after the frame (navigation may have moved mid-build) before
768    /// committing anything. Reports the outermost offender first.
769    fn preflight_tree(&self) -> Result<(), OutletError> {
770        self.preflight()?;
771        for nested in self.live_nested() {
772            nested.borrow().preflight_tree()?;
773        }
774        Ok(())
775    }
776
777    /// Attaches the outlet to its mounted widget so [`Self::present_frame`]
778    /// rebuilds outlet content every frame. Call once after mounting
779    /// [`Self::widget`]: the mount element is located through the outlet
780    /// root tag. Re-attaching replaces the previous builder harmlessly.
781    ///
782    /// Attachment claims the outlet for `runtime`'s driver domain: an
783    /// outlet nested under a live parent, or claimed by another live
784    /// runtime, is rejected before mutation. Detach (nested) or drop the
785    /// owning runtime (teardown releases) before attaching elsewhere.
786    ///
787    /// ```rust
788    /// use std::{cell::RefCell, rc::Rc};
789    /// use incular_config::Constraints;
790    /// use incular_core::{Color, Size};
791    /// use incular_navigation::{Navigator, Page};
792    /// use incular_runtime::{RouteOutlet, Runtime};
793    /// use incular_widgets::{Column, Widget};
794    ///
795    /// let navigator = Navigator::new();
796    /// let mut runtime =
797    ///     Runtime::new(Column::new(Vec::<Widget>::new()).into()).unwrap();
798    /// let parent = runtime.spawner().scope();
799    /// let outlet = Rc::new(RefCell::new(RouteOutlet::new(&navigator, &parent)));
800    /// // One-time setup: mount the outlet widget, then attach its builder.
801    /// let root = runtime.tree().root().expect("root");
802    /// let outlet_for_build = outlet.clone();
803    /// runtime
804    ///     .register_builder(root, move || {
805    ///         Column::new(vec![outlet_for_build.borrow().widget()]).into()
806    ///     })
807    ///     .unwrap();
808    /// runtime
809    ///     .run_frame(Constraints::tight(Size::new(200., 200.)))
810    ///     .unwrap();
811    /// RouteOutlet::attach(&outlet, &mut runtime).unwrap();
812    /// navigator.push_page(Page::new("a", Widget::box_(Size::new(40., 40.), Color::WHITE)));
813    /// // The single recurring operation: rebuild, frame, reconcile.
814    /// let _frame =
815    ///     RouteOutlet::present_frame(&outlet, &mut runtime, Constraints::tight(Size::new(200., 200.)))
816    ///         .unwrap();
817    /// assert!(navigator.current().is_some());
818    /// ```
819    pub fn attach(
820        outlet: &Rc<RefCell<RouteOutlet>>,
821        runtime: &mut Runtime,
822    ) -> Result<(), OutletError> {
823        {
824            let outlet = outlet.borrow();
825            if let Some(parent) = outlet.live_parent() {
826                return Err(OutletError::SeparateDrive {
827                    outlet: outlet.id(),
828                    parent: parent.borrow().id(),
829                });
830            }
831            if let Some(owner) = outlet.live_driver()
832                && owner != runtime.id()
833            {
834                return Err(OutletError::DriverConflict {
835                    outlet: outlet.id(),
836                    owner,
837                    attempted: runtime.id(),
838                });
839            }
840        }
841        let mount = {
842            let outlet = outlet.borrow();
843            runtime
844                .tree()
845                .element_with_key(&outlet.root_key)
846                .ok_or_else(|| {
847                    OutletError::Frame(TreeError::InvalidWidgetConfiguration {
848                        widget: "RouteOutlet",
849                        reason: "mount outlet.widget() in the tree before attaching".to_owned(),
850                    })
851                })?
852        };
853        // The builder carries no reactive dependencies: present_frame
854        // rebuilds it explicitly, so content never refreshes behind the
855        // transition bookkeeping's back.
856        let outlet_for_build = Rc::clone(outlet);
857        runtime
858            .register_builder(mount, move || {
859                outlet_for_build.borrow().compose_and_record()
860            })
861            .map_err(OutletError::Frame)?;
862        outlet.borrow().claim_driver(runtime);
863        outlet.borrow_mut().mount.set(Some(mount));
864        Ok(())
865    }
866
867    /// Builds the outlet widget: mounted routes in stack order with each
868    /// route's veil as a direct sibling preceding its content, so barrier
869    /// semantics block every earlier route while the veiled route itself
870    /// stays visible. The visible suffix paints from the top down to the
871    /// first opaque page inclusive; retained but covered routes stay
872    /// Builds the outlet widget for mounting, without touching any
873    /// bookkeeping: safe to call speculatively and discard. Only the
874    /// mounting builders (registered by `attach`, and nested slots)
875    /// acknowledge what they accept. Covered routes without retention
876    /// unmount (disposal); retained ones stay mounted inert while
877    /// invisible; overlay entries are left for their portal host.
878    #[must_use]
879    pub fn widget(&self) -> Widget {
880        self.compose().0
881    }
882
883    /// Composes the widget from the current routes alongside the immutable
884    /// snapshot it was built from. Pure: acquisition and composition stay
885    /// together here, publication happens only where the result mounts.
886    fn compose(&self) -> (Widget, FrameAttempt) {
887        let routes = self.navigator.routes();
888        let attempt = FrameAttempt::of(self.navigator.revision(), &routes);
889        // Top-down visibility: everything paints until (and including) the
890        // first opaque page.
891        let mut visible = vec![false; routes.len()];
892        let mut occluded = false;
893        for (index, route) in routes.iter().enumerate().rev() {
894            visible[index] = !occluded;
895            // Flutter `TransitionRoute`: a page is opaque only once its
896            // animation completes, so the route below shows while it
897            // animates in.
898            let settled = route
899                .transition
900                .route_animation()
901                .is_none_or(|animation| animation.animation().is_completed());
902            if route.presentation.is_opaque() && settled {
903                occluded = true;
904            }
905        }
906        let exiting = self.animated.borrow().exiting.clone();
907        let mut children = Vec::new();
908        // A replaced only route stays under its replacement.
909        for leaving in exiting
910            .iter()
911            .filter(|leaving| leaving.below.is_none() && leaving.until.is_some())
912        {
913            children.push(self.exiting_widget(&leaving.route));
914        }
915        for (route, visible) in routes.iter().zip(visible) {
916            // Overlay entries never mount here: `present_frame` rejects
917            // their stacks typed (before and after the frame), so reaching
918            // this skip means setup-time composition ahead of validation —
919            // still omitted, never half-mounted, and never silent through
920            // the supported path.
921            let OutletPlacement::Stacked { visible, barrier } =
922                OutletPlacement::of(&route.presentation, visible)
923            else {
924                continue;
925            };
926            if !visible && !route.presentation.maintains_state() {
927                continue;
928            }
929            // Veil before content: semantic blocking hides preceding
930            // siblings only, so the veil must precede its route's content
931            // (paint and hit order still resolve top-down correctly, since
932            // the veil sits above every earlier route). Veils follow
933            // visibility: a covered retained route keeps its content but
934            // not an active veil — the veil would dim and intercept input
935            // for content the user can no longer see.
936            if visible && let Some(barrier) = barrier {
937                children.push(self.veil_widget(route.id, &barrier));
938            }
939            children.push(self.content_widget(route, visible));
940            // Routes leaving above this one: replaced routes stay under
941            // their replacement until it settles.
942            for leaving in exiting
943                .iter()
944                .filter(|leaving| leaving.below == Some(route.id))
945            {
946                children.push(self.exiting_widget(&leaving.route));
947            }
948        }
949        // Popped routes animate out on top.
950        for leaving in exiting
951            .iter()
952            .filter(|leaving| leaving.below.is_none() && leaving.until.is_none())
953        {
954            children.push(self.exiting_widget(&leaving.route));
955        }
956        // The outlet root carries the mount tag so `attach` locates it;
957        // route tags live on the per-route wrappers inside. The root is
958        // always a stack — even empty — so mounting and unmounting the
959        // last route never flips the element kind.
960        let root: Widget = Stack::new(children).into();
961        (root.with_key(self.root_key.clone()), attempt)
962    }
963
964    /// Composes for mounting and publishes the consumed receipt: the only
965    /// path (besides [`Self::compose`]'s pure construction) that writes
966    /// [`Self::composing`], stamped with the current attempt. Called
967    /// solely by the mounting builders, so a discarded or speculative
968    /// descriptor can never authorize a frame.
969    fn compose_and_record(&self) -> Widget {
970        let (widget, attempt) = self.compose();
971        *self.composing.borrow_mut() = Some((self.epoch.get(), attempt));
972        widget
973    }
974
975    /// Composes one mounted route's content inside the outlet's own
976    /// visibility wrapper carrying the mount tag. The tag sits on the
977    /// outlet-owned wrapper — never on the presented child — so caller
978    /// keys on route content survive navigation untouched, and the tag
979    /// element stays stable across visibility flips.
980    fn content_widget(&self, route: &Route, visible: bool) -> Widget {
981        // `ModalRoute.of` for everything the route presents, when enabled.
982        let content = if self.modal_route_scopes {
983            Widget::environment_scope(
984                ModalRoute::new(route.clone(), self.navigator.clone()),
985                route.presented_child(),
986            )
987        } else {
988            route.presented_child()
989        };
990        let mut wrapped: Widget = Visibility::new(content)
991            .visible(visible)
992            .maintain_state(true)
993            .into();
994        if !visible {
995            wrapped = ExcludeFocusTraversal::new(wrapped).into();
996        }
997        wrapped.with_key(self.tag_for(route.id))
998    }
999
1000    /// A popped or replaced route still animating: its content under its
1001    /// old tag, so it keeps its state, without input or focus.
1002    fn exiting_widget(&self, route: &Route) -> Widget {
1003        let content: Widget = IgnorePointer::new(route.presented_child()).into();
1004        Widget::from(ExcludeFocusTraversal::new(content)).with_key(self.tag_for(route.id))
1005    }
1006
1007    /// Flutter's route animation lifecycle, once per frame before
1008    /// composition: pushed routes animate in (routes present on the first
1009    /// frame start complete, as `didAdd` does), a popped route animates out
1010    /// on top, a replaced route stays under its replacement until that
1011    /// completes, and every route's secondary animation follows the route
1012    /// above it.
1013    fn drive_route_animations(&self) {
1014        let routes = self.navigator.routes();
1015        let mut state = self.animated.borrow_mut();
1016        let initial = !state.started;
1017        state.started = true;
1018        let mut pushed = Vec::new();
1019        for route in &routes {
1020            if state.seen.insert(route.id) {
1021                if let Some(animation) = route.transition.route_animation() {
1022                    let controller = animation.animation();
1023                    if initial {
1024                        controller.set_value(1.0);
1025                    } else if controller.is_dismissed() {
1026                        controller.forward_on_next_tick();
1027                    }
1028                }
1029                pushed.push(route.id);
1030            }
1031        }
1032        let live: HashSet<RouteId> = routes.iter().map(|route| route.id).collect();
1033        let previous = std::mem::take(&mut state.presented);
1034        let top = routes.last().map(|route| route.id);
1035        let previous_top = previous.last().map(|route| route.id);
1036        for old in &previous {
1037            if live.contains(&old.id) {
1038                continue;
1039            }
1040            state.seen.remove(&old.id);
1041            let Some(animation) = old.transition.route_animation() else {
1042                continue;
1043            };
1044            let controller = animation.animation();
1045            // Only the top route animates out; a route removed from below
1046            // goes at once, as Flutter's `removeRoute` does.
1047            if previous_top != Some(old.id) || controller.is_dismissed() {
1048                continue;
1049            }
1050            if let Some(replacement) = top.filter(|top| pushed.contains(top)) {
1051                // `pushReplacement`: the old page stays, as it was, under the
1052                // new one until the new one has animated in.
1053                let below = (routes.len() > 1).then(|| routes[routes.len() - 2].id);
1054                state.exiting.push(Exiting {
1055                    route: old.clone(),
1056                    below,
1057                    until: Some(replacement),
1058                });
1059            } else {
1060                // `pop`: the route animates out on top of the stack.
1061                controller.reverse_on_next_tick();
1062                state.exiting.push(Exiting {
1063                    route: old.clone(),
1064                    below: None,
1065                    until: None,
1066                });
1067            }
1068        }
1069        let settled = |id: RouteId| {
1070            routes
1071                .iter()
1072                .find(|route| route.id == id)
1073                .and_then(|route| route.transition.route_animation())
1074                .is_none_or(|animation| !animation.animation().is_animating())
1075        };
1076        state.exiting.retain(|leaving| match leaving.until {
1077            Some(replacement) => !settled(replacement),
1078            None => leaving
1079                .route
1080                .transition
1081                .route_animation()
1082                .is_some_and(|animation| !animation.animation().is_dismissed()),
1083        });
1084        state.presented = routes.clone();
1085        // Secondary animations: each route follows the route presented
1086        // above it, a leaving route included (Flutter `didPopNext`).
1087        let mut order: Vec<&Route> = Vec::with_capacity(routes.len() + state.exiting.len());
1088        order.extend(
1089            state
1090                .exiting
1091                .iter()
1092                .filter(|leaving| leaving.below.is_none() && leaving.until.is_some())
1093                .map(|leaving| &leaving.route),
1094        );
1095        for route in &routes {
1096            order.push(route);
1097            order.extend(
1098                state
1099                    .exiting
1100                    .iter()
1101                    .filter(|leaving| leaving.below == Some(route.id))
1102                    .map(|leaving| &leaving.route),
1103            );
1104        }
1105        order.extend(
1106            state
1107                .exiting
1108                .iter()
1109                .filter(|leaving| leaving.below.is_none() && leaving.until.is_none())
1110                .map(|leaving| &leaving.route),
1111        );
1112        for (index, route) in order.iter().enumerate() {
1113            let Some(animation) = route.transition.route_animation() else {
1114                continue;
1115            };
1116            let next = order[index + 1..]
1117                .iter()
1118                .find_map(|above| above.transition.route_animation())
1119                .map(|above| above.animation());
1120            let secondary = animation.secondary_animation();
1121            let current = secondary.parent().map(|parent| parent.controllers());
1122            let wanted = next.as_ref().map(|next| vec![next.clone()]);
1123            if current.as_deref() != Some(wanted.as_deref().unwrap_or(&[])) {
1124                secondary.set_parent(Some(
1125                    next.map_or_else(always_dismissed, DoubleAnimation::from),
1126                ));
1127            }
1128        }
1129    }
1130
1131    /// Veils the outlet area for one route through the shared animated
1132    /// barrier mechanism: unconditional pointer interception, background
1133    /// semantics blocked for every preceding sibling, and dismissal that
1134    /// pops the barrier's own route — never whatever happens to be on top.
1135    /// The veil is a direct stack sibling preceding its content (not nested
1136    /// inside it) so semantic blocking reaches across routes while the
1137    /// route's own content stays visible; it is sized to the outlet area
1138    /// because an unsized barrier container collapses and lets taps fall
1139    /// through to the supposedly blocked content.
1140    fn veil_widget(&self, route: RouteId, barrier: &ModalBarrier) -> Widget {
1141        let navigator = self.navigator.clone();
1142        let barrier = barrier.clone();
1143        Widget::from(LayoutBuilder::new(move |_, constraints| {
1144            // Bounded hosts (windows, dialogs) size the veil to the outlet
1145            // area; unbounded heights fall back to the raw veil, which still
1146            // blocks its own content but cannot cover an unknown area.
1147            let size = constraints.biggest();
1148            let navigator = navigator.clone();
1149            let mut veil = AnimatedModalBarrier::with_flat_color(barrier.color)
1150                .dismissible(barrier.dismissible);
1151            if let Some(label) = barrier.label.clone() {
1152                veil = veil.semantics_label(label);
1153            }
1154            let veiled: Widget = veil
1155                .on_dismiss(move || {
1156                    if navigator
1157                        .current()
1158                        .is_some_and(|current| current.id == route)
1159                    {
1160                        navigator.pop();
1161                    }
1162                })
1163                .into();
1164            if size.width.is_finite() && size.height.is_finite() {
1165                SizedBox::from_dimensions(Some(size.width), Some(size.height), Some(veiled)).into()
1166            } else {
1167                veiled
1168            }
1169        }))
1170        .block_semantics()
1171    }
1172
1173    /// Returns the route task scope for spawning route-bound work, if the
1174    /// route is currently mounted and bound.
1175    #[must_use]
1176    pub fn route_task_scope(&self, route: RouteId) -> Option<TaskScope> {
1177        self.bindings
1178            .get(&route)
1179            .map(|binding| binding.scope().clone())
1180    }
1181
1182    /// Presents one production frame through the outlet: validates the
1183    /// tree, captures any pending transition, rebuilds outlet content,
1184    /// runs layout, then commits the transition against the mounted tree.
1185    /// This is the supported recurring operation: the save runs before
1186    /// the frame reconciles (rebuilding may unmount covered content and
1187    /// the drain clears dead focus), while the restore runs after layout
1188    /// when targets are eligible.
1189    ///
1190    /// Takes the outlet shared (rather than `&mut self`) because the
1191    /// attached builder reenters it immutably while the frame runs; short
1192    /// borrows never span the frame. Driving claims the whole tree for
1193    /// `runtime`: presenting a nested child directly, or through a second
1194    /// live runtime, fails before any mutation. A rebuild or frame failure
1195    /// keeps the pending capture for a safe retry instead of consuming the
1196    /// activation, and navigation mid-frame abandons the stale capture
1197    /// without touching committed records.
1198    pub fn present_frame(
1199        outlet: &Rc<RefCell<Self>>,
1200        runtime: &mut Runtime,
1201        constraints: Constraints,
1202    ) -> Result<(DisplayList, FrameStats), OutletError> {
1203        Self::present_frame_at(
1204            outlet,
1205            runtime,
1206            constraints,
1207            incular_core::time::Instant::now(),
1208        )
1209    }
1210
1211    /// [`Self::present_frame`] at a given frame time, for deterministic
1212    /// animation tests and embedders with their own clock.
1213    pub fn present_frame_at(
1214        outlet: &Rc<RefCell<Self>>,
1215        runtime: &mut Runtime,
1216        constraints: Constraints,
1217        now: incular_core::time::Instant,
1218    ) -> Result<(DisplayList, FrameStats), OutletError> {
1219        // Unsupported stacks anywhere in the tree fail before revisions,
1220        // capture, rebuild, frame, or reconcile — nothing mounted or
1221        // committed changes on this path. Driver conflicts fail next, also
1222        // before any mutation; the claim lands only on this success path.
1223        outlet.borrow().preflight_tree()?;
1224        outlet.borrow().check_drivers(runtime, true)?;
1225        outlet.borrow().claim_tree(runtime);
1226        {
1227            let mut outlet = outlet.borrow_mut();
1228            outlet.begin_frame(runtime);
1229        }
1230        // The fallible section runs builders, layout, and reconciliation —
1231        // all application-reachable code. The attempt guard owns cleanup:
1232        // every early return — and panic unwind — drops it armed and
1233        // resets the schedule memos, since a failed present consumes
1234        // nothing and the next success must re-evaluate outstanding work.
1235        // Neither path repairs tree state — recovery stays the retry's
1236        // job.
1237        let mut attempt = AttemptGuard::arm(outlet);
1238        // Rebuild outlet content explicitly before framing: reactive
1239        // dependencies alone cannot cover content the host descriptors
1240        // never mention, and an explicit rebuild keeps mounting ordered
1241        // with the capture above. A vanished mount (unmounted content
1242        // prunes its builder) detaches gracefully instead of erroring —
1243        // or panicking — every frame. The existence check precedes the
1244        // rebuild because pruned builders no longer fail with
1245        // `MissingElement`.
1246        let attached = outlet
1247            .borrow()
1248            .mount
1249            .get()
1250            .filter(|mount| runtime.tree().element_exists(*mount));
1251        if attached.is_none() {
1252            outlet.borrow_mut().mount.set(None);
1253        }
1254        if let Some(mount) = attached {
1255            match runtime.rebuild_from_builder(mount) {
1256                Ok(()) => {}
1257                Err(TreeError::MissingElement(_)) => {
1258                    outlet.borrow_mut().mount.set(None);
1259                }
1260                Err(error) => return Err(OutletError::Frame(error)),
1261            }
1262        }
1263        // No post-rebuild sampling here: each outlet's builders publish
1264        // what they actually consume (see `consumed`), and reconciliation
1265        // adopts it. Sampling afterward would credit the rebuild with
1266        // navigation it never saw.
1267        let output = runtime.run_frame_at(constraints, now)?;
1268        // A preflight snapshot cannot authorize later content: builders
1269        // may have navigated mid-frame, so the tree re-validates before
1270        // anything commits. On failure the pending capture survives for a
1271        // retry (which preflights first anyway) and no commit, bind,
1272        // prune, or restore runs — the error names the offender instead
1273        // of silently omitting its content.
1274        outlet.borrow().preflight_tree()?;
1275        outlet.borrow_mut().reconcile_tree(runtime);
1276        attempt.disarm();
1277        // Successful-but-stale output schedules its own follow-up: paint
1278        // may lag the bookkeeping by a frame, and the run_frame reset
1279        // wipes anything flagged mid-frame. Quiet frames schedule
1280        // nothing; errors never reach here.
1281        outlet.borrow().flag_stale_frames(runtime);
1282        Ok(output)
1283    }
1284
1285    /// Captures a pending transition when none is outstanding and the
1286    /// active route moved since the last commit. The outgoing focus is
1287    /// read once into the pending slot — never into the records — alongside
1288    /// the attempt snapshot, so a later retry cannot overwrite it with
1289    /// intermediate focus, and abandoning the capture drops it without
1290    /// touching committed state.
1291    fn capture_transition(&mut self, runtime: &Runtime) {
1292        if self.pending.is_some() {
1293            return;
1294        }
1295        let active = self.navigator.current().map(|route| route.id);
1296        if active == self.presented {
1297            return;
1298        }
1299        let saved = self.presented.and_then(|previous| {
1300            self.navigator
1301                .lifetime_of(previous)
1302                .is_some()
1303                .then(|| self.focus.read_owned_focus(runtime, previous))
1304                .flatten()
1305        });
1306        self.pending = Some(PendingTransition {
1307            previous: self.presented,
1308            incoming: active,
1309            saved,
1310            attempt: FrameAttempt::capture(&self.navigator),
1311        });
1312    }
1313
1314    /// Commits the pending transition when the frame actually presented
1315    /// it: the active route still matches the captured incoming route, the
1316    /// incoming lifetime is alive, and the attempt snapshot still covers
1317    /// the navigator — so the mounted content is the transition's own.
1318    /// Anything else — navigation mid-frame beyond order/child-only
1319    /// changes, or a removed incoming route — abandons the capture and
1320    /// re-captures fresh, leaving committed records alone. Returns whether
1321    /// a transition committed.
1322    fn commit_transition(&mut self, runtime: &mut Runtime) -> bool {
1323        let active = self.navigator.current().map(|route| route.id);
1324        let Some(pending) = self.pending.take() else {
1325            return false;
1326        };
1327        if active != pending.incoming
1328            || pending
1329                .incoming
1330                .is_some_and(|incoming| self.navigator.lifetime_of(incoming).is_none())
1331            || !pending.attempt.covers(&self.navigator)
1332        {
1333            // Stale capture (already taken above): re-capture fresh for the
1334            // route that actually won, without writing anything first.
1335            self.capture_transition(runtime);
1336            return false;
1337        }
1338        if let Some(previous) = pending.previous {
1339            self.focus.commit_saved(previous, pending.saved);
1340        }
1341        self.presented = active;
1342        self.pending = None;
1343        true
1344    }
1345
1346    /// Drops records, bindings, and tags for permanently removed routes.
1347    /// Lifetime-end notifications already fired at commit time, so bound
1348    /// scopes were cancelled before their bindings drop here. Unmounted
1349    /// but live routes (covered, retained, disposal-unmounted) keep
1350    /// everything: removal is a lifetime event, not a mount event.
1351    fn prune_removed(&mut self) {
1352        self.focus.retain_mounted(&self.navigator);
1353        self.bindings
1354            .retain(|id, _| self.navigator.lifetime_of(*id).is_some());
1355        let animated = self.animated.borrow();
1356        self.tags.borrow_mut().retain(|id, _| {
1357            self.navigator.lifetime_of(*id).is_some()
1358                || animated
1359                    .exiting
1360                    .iter()
1361                    .any(|leaving| leaving.route.id == *id)
1362        });
1363    }
1364
1365    /// Binds every live route lacking a binding. Scope ownership follows
1366    /// the route lifetime — not mounted content: covered, retained, and
1367    /// even disposal-unmounted routes stay bound while alive, so their
1368    /// tasks outlive their pixels; only permanent removal ends them.
1369    /// Focus records are the mounted-content side of the integration
1370    /// (attributed through the live tree); bindings are the lifetime side
1371    /// (keyed by navigator state, including routes with no pixels).
1372    fn ensure_bindings(&mut self) {
1373        for route in self.navigator.routes() {
1374            if !self.bindings.contains_key(&route.id)
1375                && let Some(lifetime) = self.navigator.lifetime_of(route.id)
1376            {
1377                self.bindings.insert(
1378                    route.id,
1379                    RouteTaskBinding::bind(&self.task_parent, &lifetime),
1380                );
1381            }
1382        }
1383    }
1384
1385    /// Restores on committed transitions; otherwise retries a pending
1386    /// restore only when it cannot displace valid focus (deferred content
1387    /// may have mounted or enabled through ordinary invalidation since).
1388    fn restore_or_retry(&mut self, runtime: &mut Runtime, transitioned: bool) {
1389        let Some(id) = self.navigator.current().map(|route| route.id) else {
1390            return;
1391        };
1392        if transitioned {
1393            self.focus.restore_saved(runtime, &self.navigator, id);
1394        } else {
1395            self.focus.restore_if_vacant(runtime, &self.navigator, id);
1396        }
1397    }
1398
1399    /// Promotes the in-flight composition into the published snapshot and
1400    /// adopts it into the pending attempt — without touching the outgoing
1401    /// save — so the commit judges built content, and retries describe
1402    /// their own build. Runs only on the success path (reconciliation),
1403    /// so failed composition publishes nothing; skipped builders simply
1404    /// re-promote their previous publication. Only mounting builders
1405    /// write the slot, and only the current attempt's receipt adopts: a
1406    /// stale receipt (failed attempt, predated capture) authorizes
1407    /// nothing even at a matching revision. Within the attempt, only
1408    /// newer-or-equal snapshots relabel. All values are immutable copies,
1409    /// so application callbacks running later in reconciliation cannot
1410    /// relabel built content.
1411    fn adopt_consumed(&mut self) {
1412        let Some((epoch, composing)) = self.composing.borrow().clone() else {
1413            return;
1414        };
1415        if epoch != self.epoch.get() {
1416            return;
1417        }
1418        if let Some(pending) = self.pending.as_mut()
1419            && composing.revision >= pending.attempt.revision
1420        {
1421            pending.attempt = composing.clone();
1422        }
1423        self.consumed = Some(composing);
1424    }
1425
1426    /// Schedules follow-up frames for stale output through the runtime's
1427    /// own invalidation (`request_frame`) — but only for revisions never
1428    /// scheduled before. Successful output older than live navigation
1429    /// still requires a frame, and ordinary hosts must not poll
1430    /// [`Self::needs_frame`] to discover it; the edge trigger keeps
1431    /// unconsumable staleness (and every quiet frame) from scheduling
1432    /// anything. Errors return before reconciliation, so rejected stacks
1433    /// schedule no retry loop. Nested staleness wakes the root driver
1434    /// through the same cascade. The per-revision suppression loses no
1435    /// outstanding demand: failures and panics reset it, and every new
1436    /// navigation (including slot remounts) carries a new revision.
1437    fn flag_stale_frames(&self, runtime: &mut Runtime) {
1438        let live = self.navigator.revision();
1439        let stale = match &self.consumed {
1440            None => !self.navigator.routes().is_empty(),
1441            Some(consumed) => consumed.revision != live,
1442        };
1443        if stale && self.signaled.get() != live {
1444            self.signaled.set(live);
1445            runtime.request_frame();
1446        }
1447        for nested in self.live_nested() {
1448            nested.borrow().flag_stale_frames(runtime);
1449        }
1450    }
1451
1452    /// Clears schedule memos across the tree. A failed present consumes
1453    /// nothing, so its suppression must not survive: the next success
1454    /// re-evaluates outstanding work from scratch.
1455    fn reset_signaled_tree(&self) {
1456        self.signaled.set(0);
1457        for nested in self.live_nested() {
1458            nested.borrow().reset_signaled_tree();
1459        }
1460    }
1461
1462    /// Post-frame reconciliation: adopt the consumed snapshot, commit the
1463    /// pending transition against it, then prune, bind, and restore or
1464    /// retry. The commit runs before pruning so a just-committed record
1465    /// for a route removed mid-frame is dropped rather than resurrected.
1466    fn reconcile_presented(&mut self, runtime: &mut Runtime) {
1467        self.adopt_consumed();
1468        let transitioned = self.commit_transition(runtime);
1469        self.prune_removed();
1470        self.ensure_bindings();
1471        self.restore_or_retry(runtime, transitioned);
1472    }
1473
1474    /// Bumps this outlet and every nested outlet, then captures pending
1475    /// transitions top-down. One call drives the whole outlet tree, so a
1476    /// single frame presents nested navigation without rendering the
1477    /// runtime once per outlet. The topology guarantees (acyclic,
1478    /// single-parent) make every participant reachable exactly once.
1479    fn begin_frame(&mut self, runtime: &Runtime) {
1480        self.epoch.set(self.epoch.get().wrapping_add(1));
1481        self.drive_route_animations();
1482        self.revision.set(
1483            self.revision
1484                .get()
1485                .checked_add(1)
1486                .expect("revision exhausted"),
1487        );
1488        self.capture_transition(runtime);
1489        for nested in self.live_nested() {
1490            nested.borrow_mut().begin_frame(runtime);
1491        }
1492    }
1493
1494    /// Reconciles this outlet and every nested outlet after a frame,
1495    /// children first so the outer context wins focus ties
1496    /// deterministically. Dead nested handles prune here, keeping
1497    /// registrations bounded no matter how often hosts replace outlets.
1498    fn reconcile_tree(&mut self, runtime: &mut Runtime) {
1499        for nested in self.live_nested() {
1500            nested.borrow_mut().reconcile_tree(runtime);
1501        }
1502        self.reconcile_presented(runtime);
1503        self.nested
1504            .borrow_mut()
1505            .retain(|weak| weak.upgrade().is_some());
1506    }
1507
1508    /// Stable mount tag for a route, assigned once while the outlet lives.
1509    /// Tags are never reused, so a re-pushed route mounts fresh even under
1510    /// the same page key.
1511    fn tag_for(&self, route: RouteId) -> Key {
1512        if let Some(key) = self.tags.borrow().get(&route) {
1513            return key.clone();
1514        }
1515        let mut tags = self.tags.borrow_mut();
1516        if let Some(key) = tags.get(&route) {
1517            return key.clone();
1518        }
1519        let sequence = self.tag_sequence.get();
1520        self.tag_sequence.set(sequence.wrapping_add(1));
1521        let key = Key::String(format!("route-outlet-{}-{sequence}", self.namespace));
1522        tags.insert(route, key.clone());
1523        key
1524    }
1525}
1526
1527/// The outlet's route animation bookkeeping.
1528#[derive(Default)]
1529struct RouteAnimations {
1530    /// Whether a frame has run: routes present on the first frame start
1531    /// complete rather than animating in.
1532    started: bool,
1533    /// Routes whose push animation has been started.
1534    seen: HashSet<RouteId>,
1535    /// The routes the previous frame presented.
1536    presented: Vec<Route>,
1537    /// Routes removed from the navigator but still presented.
1538    exiting: Vec<Exiting>,
1539}
1540
1541/// A route removed from the navigator that the outlet still presents.
1542#[derive(Clone)]
1543struct Exiting {
1544    route: Route,
1545    /// The live route it is presented above, or `None` for the top.
1546    below: Option<RouteId>,
1547    /// For a replaced route, the replacement whose animation it waits for;
1548    /// a popped route waits for its own reverse animation.
1549    until: Option<RouteId>,
1550}