Skip to main content

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}