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(¤t, 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(¤t, 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}