incular_runtime/route_focus.rs
1//! Per-route focus save/restore policy over navigation lifetimes.
2//!
3//! The state is deliberately pull-driven: it owns the save records and the
4//! restore validation, while the mount integration drives it — save the
5//! outgoing route on deactivation, restore the incoming route on
6//! reactivation, and drop records through [`RouteFocusState::forget`] or
7//! [`RouteFocusState::retain_mounted`] on permanent removal. There is no
8//! background watcher and no second focus engine; restoration funnels
9//! through the runtime's own focus slot.
10
11use std::{collections::HashMap, rc::Rc};
12
13use incular_navigation::{Navigator, RouteId};
14use incular_widgets::internal::{ElementId, WidgetTree};
15
16use super::frame::Runtime;
17
18/// Maps a mounted element to the route that owns it.
19///
20/// The mount integration supplies this attribution from mounted structure
21/// (keys, scope containment); the state never guesses ownership, so an
22/// element without an owner is never restored. Route identities are
23/// navigator-scoped, so use one state per [`Navigator`].
24pub type RouteFocusOwner = Rc<dyn Fn(&WidgetTree, ElementId) -> Option<RouteId>>;
25
26/// Remembers which element was focused per route and restores it on return.
27///
28/// Mount autofocus fires once per window, so route activation is what
29/// focuses incoming autofocus targets — never a background scan. Restore
30/// is otherwise fail-closed, with three record states and three restore
31/// behaviors:
32/// - No saved information (never saved, forgotten, or saved with nothing
33/// owned): restore leaves focus untouched, except for genuinely orphaned
34/// focus (see below).
35/// - Previously saved focus that is no longer eligible: the deterministic
36/// fallback clears focus, but only when current focus is itself orphaned —
37/// an invalid saved record never steals unrelated focus.
38/// - Focus currently owned by another route: saves reject it (a route
39/// records only its own focus) and restores leave it alone while its
40/// owner stays mounted.
41/// - No usable record at all: a vacant stage (no focus, or only orphaned
42/// focus) takes the route's own autofocus target when it names one;
43/// valid focus is never displaced for a missing record.
44///
45/// Eligibility reuses the tree's authoritative focus rule instead of a
46/// parallel predicate: the target must be live in the arena (detached
47/// elements fail here, including reused slots whose generation changed)
48/// and a member of [`WidgetTree::focusable_elements`], which already
49/// encodes enabled state, `ExcludeFocus`, and inactive subtrees
50/// (`IndexedStack` non-indexed children, `descendants_are_focusable`).
51/// Hidden-but-mounted subtrees (`Visibility`, `Offstage`) stay eligible by
52/// framework design — they retain focus — while ownership comes from the
53/// [`RouteFocusOwner`] oracle.
54///
55/// Route identity and element identity stay separate throughout: records
56/// are keyed by [`RouteId`], targets are [`ElementId`] values validated
57/// against the live tree on every restore, never by index or name.
58///
59/// Dropping the state forgets all records; later restores are impossible,
60/// which is the teardown policy.
61pub struct RouteFocusState {
62 saved: HashMap<RouteId, Option<ElementId>>,
63 owner_of: RouteFocusOwner,
64}
65
66impl RouteFocusState {
67 /// Creates focus state attributing elements through `owner_of`.
68 pub fn new(owner_of: impl Fn(&WidgetTree, ElementId) -> Option<RouteId> + 'static) -> Self {
69 Self {
70 saved: HashMap::new(),
71 owner_of: Rc::new(owner_of),
72 }
73 }
74
75 /// Records the currently focused element as `route`'s focus. Call when
76 /// `route` deactivates. Only focus owned by `route` is recorded: focus
77 /// owned elsewhere (or by nothing) records no information rather than
78 /// a foreign snapshot, so retained records never lie about ownership.
79 /// Absent and rejected records restore identically (orphan sweep), so
80 /// validation is record hygiene, not a second restore predicate.
81 pub fn save_focused(&mut self, runtime: &Runtime, route: RouteId) {
82 let focused = self.read_owned_focus(runtime, route);
83 self.saved.insert(route, focused);
84 }
85
86 /// Reads the currently focused element when owned by `route`, without
87 /// touching records. Transactional drivers capture through this and
88 /// commit through [`Self::commit_saved`], so a failed attempt never
89 /// disturbs committed state.
90 pub(crate) fn read_owned_focus(&self, runtime: &Runtime, route: RouteId) -> Option<ElementId> {
91 runtime.focused_element().filter(|id| {
92 runtime.tree().element_exists(*id)
93 && (self.owner_of)(runtime.tree(), *id) == Some(route)
94 })
95 }
96
97 /// Commits a previously captured save into `route`'s record.
98 pub(crate) fn commit_saved(&mut self, route: RouteId, saved: Option<ElementId>) {
99 self.saved.insert(route, saved);
100 }
101
102 /// Restores `route`'s saved focus into `runtime`. Call when `route`
103 /// reactivates. A removed `route` restores nothing even with a leftover
104 /// record, so cleanup never depends on the host having forgotten first.
105 pub fn restore_saved(&mut self, runtime: &mut Runtime, navigator: &Navigator, route: RouteId) {
106 if navigator.lifetime_of(route).is_none() {
107 return;
108 }
109 if let Some(saved) = self.saved.get(&route).copied().flatten()
110 && Self::eligible(runtime, &self.owner_of, route, saved)
111 {
112 if runtime.focused_element() != Some(saved) {
113 runtime.set_focus(Some(saved));
114 }
115 return;
116 }
117 // No usable record. Fresh starts focus the route's own autofocus
118 // target, but only onto a vacant stage — valid focus is never
119 // displaced for a missing record.
120 let current = runtime.focused_element();
121 let vacant =
122 current.is_none_or(|id| Self::orphaned(runtime, navigator, &self.owner_of, id));
123 if vacant
124 && let Some(auto) = runtime.tree().autofocus_element()
125 && (self.owner_of)(runtime.tree(), auto) == Some(route)
126 {
127 runtime.set_focus(Some(auto));
128 return;
129 }
130 if let Some(current) = current
131 && Self::orphaned(runtime, navigator, &self.owner_of, current)
132 {
133 runtime.set_focus(None);
134 }
135 }
136
137 /// Eligibility is the tree's own rule plus attribution: live, focusable
138 /// (hence enabled and in an active subtree), and owned by `route`.
139 fn eligible(
140 runtime: &Runtime,
141 owner_of: &RouteFocusOwner,
142 route: RouteId,
143 id: ElementId,
144 ) -> bool {
145 runtime.tree().element_exists(id)
146 && runtime.tree().focusable_elements().contains(&id)
147 && owner_of(runtime.tree(), id) == Some(route)
148 }
149
150 /// Orphaned focus has no live owner to keep it: a dead or unfocusable
151 /// target, or a target whose owning route is no longer mounted. Focus
152 /// owned by a mounted route — or by nothing attributable — is left
153 /// alone.
154 fn orphaned(
155 runtime: &Runtime,
156 navigator: &Navigator,
157 owner_of: &RouteFocusOwner,
158 id: ElementId,
159 ) -> bool {
160 !runtime.tree().element_exists(id)
161 || !runtime.tree().focusable_elements().contains(&id)
162 || owner_of(runtime.tree(), id)
163 .is_some_and(|owner| navigator.lifetime_of(owner).is_none())
164 }
165
166 /// Restores `route` only when no valid focus would be displaced: the
167 /// record exists and current focus is absent or itself orphaned. This
168 /// is how deferred content (mounted or enabled through ordinary frame
169 /// invalidation after the transition) still restores without callers
170 /// retrying arbitrarily — retries never yank live focus.
171 pub(crate) fn restore_if_vacant(
172 &mut self,
173 runtime: &mut Runtime,
174 navigator: &Navigator,
175 route: RouteId,
176 ) {
177 if !matches!(self.saved.get(&route), Some(Some(_))) {
178 return;
179 }
180 let vacant = runtime
181 .focused_element()
182 .is_none_or(|current| Self::orphaned(runtime, navigator, &self.owner_of, current));
183 if vacant {
184 self.restore_saved(runtime, navigator, route);
185 }
186 }
187
188 /// Drops `route`'s record on permanent removal. A removed route never
189 /// restores: without this call a stale record would outlive its route.
190 pub fn forget(&mut self, route: RouteId) {
191 self.saved.remove(&route);
192 }
193
194 /// Drops records for routes no longer mounted in `navigator`. Call
195 /// after declarative reconciliation, which can remove routes without
196 /// an explicit per-route removal call.
197 pub fn retain_mounted(&mut self, navigator: &Navigator) {
198 self.saved
199 .retain(|route, _| navigator.lifetime_of(*route).is_some());
200 }
201}