Skip to main content

aetna_core/state/
focus.rs

1//! Focus traversal helpers for [`UiState`](super::UiState).
2
3use web_time::Instant;
4
5use crate::event::UiTarget;
6use crate::focus::focus_order;
7use crate::tree::El;
8
9use super::UiState;
10
11impl UiState {
12    pub fn sync_focus_order(&mut self, root: &El) {
13        let order = focus_order(root, self);
14        self.focus.order = order;
15        if let Some(focused) = &self.focused {
16            if let Some(current) = self
17                .focus
18                .order
19                .iter()
20                .find(|t| t.node_id == focused.node_id)
21            {
22                self.focused = Some(current.clone());
23                return;
24            }
25            // Focus order excludes nodes whose rect doesn't intersect
26            // their inherited clip, so a focused widget that scrolled
27            // out of view (or whose ancestor scroll viewport just
28            // shrunk underneath it) is no longer in `order`. That's
29            // not the same as "focus target is gone" — the node still
30            // exists in the tree, it's just visually clipped.
31            // Clearing focus here would dismiss the soft keyboard the
32            // moment a phone's on-screen keyboard shrinks the layout
33            // viewport (Android's default), which is exactly what
34            // happens when the user taps a text input. Match HTML's
35            // shape: only clear when the node truly leaves the tree.
36            if !node_exists(root, &focused.node_id) {
37                self.focused = None;
38            }
39        }
40    }
41
42    pub fn set_focus(&mut self, target: Option<UiTarget>) {
43        let Some(target) = target else {
44            self.focused = None;
45            return;
46        };
47        if self.focus.order.iter().any(|f| f.node_id == target.node_id) {
48            let changed = self.focused.as_ref().map(|f| &f.node_id) != Some(&target.node_id);
49            self.focused = Some(target);
50            if changed {
51                self.bump_caret_activity(Instant::now());
52            }
53        }
54    }
55
56    /// Queue programmatic focus requests by key. Each entry is
57    /// resolved once per `prepare_layout`, after the focus order has
58    /// been rebuilt: matching keys focus the corresponding node;
59    /// unmatched keys are dropped silently. Hosts call this once per
60    /// frame from [`crate::event::App::drain_focus_requests`]; apps
61    /// that own a `Runner` can also push directly for tests.
62    pub fn push_focus_requests(&mut self, keys: Vec<String>) {
63        self.focus.pending_requests.extend(keys);
64    }
65
66    /// Drain the queued focus requests, resolving each by key against
67    /// the current focus order. The last successfully-resolved key
68    /// wins. Called by `prepare_layout` after `sync_popover_focus` so
69    /// explicit requests override popover auto-focus.
70    pub fn drain_focus_requests(&mut self) {
71        let keys = std::mem::take(&mut self.focus.pending_requests);
72        for key in keys {
73            if let Some(target) = self.focus.order.iter().find(|t| t.key == key).cloned() {
74                self.set_focus(Some(target));
75            }
76        }
77    }
78
79    /// Set whether the current focus should display its focus ring.
80    /// The runtime calls this from input-handling paths: pointer-down
81    /// clears it (`false`), Tab and arrow-nav raise it (`true`). Apps
82    /// that move focus programmatically can also flip it explicitly,
83    /// e.g. force the ring on after restoring focus from an off-screen
84    /// menu close. See [`UiState::focus_visible`].
85    pub fn set_focus_visible(&mut self, visible: bool) {
86        self.focus_visible = visible;
87    }
88
89    pub fn focus_next(&mut self) -> Option<&UiTarget> {
90        self.move_focus(1)
91    }
92
93    pub fn focus_prev(&mut self) -> Option<&UiTarget> {
94        self.move_focus(-1)
95    }
96
97    fn move_focus(&mut self, delta: isize) -> Option<&UiTarget> {
98        if self.focus.order.is_empty() {
99            self.focused = None;
100            return None;
101        }
102        let current = self.focused.as_ref().and_then(|target| {
103            self.focus
104                .order
105                .iter()
106                .position(|t| t.node_id == target.node_id)
107        });
108        let len = self.focus.order.len() as isize;
109        let next = match current {
110            Some(current) => (current as isize + delta).rem_euclid(len) as usize,
111            None if delta < 0 => self.focus.order.len() - 1,
112            None => 0,
113        };
114        self.focused = Some(self.focus.order[next].clone());
115        self.focused.as_ref()
116    }
117}
118
119/// True iff `id` matches the `computed_id` of `root` or any descendant.
120/// Used by [`UiState::sync_focus_order`] to distinguish "focused node
121/// scrolled out of clip" (keep focus) from "focused node removed from
122/// tree" (clear focus).
123fn node_exists(root: &El, id: &str) -> bool {
124    if root.computed_id == id {
125        return true;
126    }
127    root.children.iter().any(|c| node_exists(c, id))
128}
129
130#[cfg(test)]
131mod tests {
132    use crate::layout::layout;
133    use crate::state::UiState;
134
135    /// A focused widget that scrolls out of view (its rect leaves the
136    /// scroll's clip rect) must keep focus, not lose it. The web
137    /// host's soft-keyboard sync polls focus every frame and dismisses
138    /// the keyboard whenever focus is gone, so clearing focus on a
139    /// scroll-out turned every quick tap on a phone text input into
140    /// "summon then immediately dismiss the keyboard."
141    #[test]
142    fn focused_node_outside_clip_keeps_focus() {
143        use crate::tree::*;
144        // Scroll viewport 100px tall containing two 80px-tall
145        // focusable rows. Without scrolling, only the first row sits
146        // inside the clip; focus the second row and shrink the
147        // viewport so it falls outside, then verify focus survives.
148        let mut tree = crate::scroll([
149            crate::widgets::button::button("a")
150                .key("a")
151                .height(Size::Fixed(80.0)),
152            crate::widgets::button::button("b")
153                .key("b")
154                .height(Size::Fixed(80.0)),
155        ])
156        .height(Size::Fill(1.0));
157        let mut state = UiState::new();
158        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
159        state.sync_focus_order(&tree);
160        let target = state
161            .focus
162            .order
163            .iter()
164            .find(|t| t.key == "b")
165            .cloned()
166            .expect("b in focus order");
167        state.set_focus(Some(target));
168        assert_eq!(
169            state.focused.as_ref().map(|t| t.key.as_str()),
170            Some("b"),
171            "focus should land on b before reflow",
172        );
173        // Shrink the viewport so the scroll's clip can no longer fit
174        // both buttons; b's rect (80..160) is partially inside the
175        // 0..120 clip and should still be picked up. Drop further so
176        // b is fully outside the clip (clip 0..50, b at 80..160).
177        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 50.0));
178        state.sync_focus_order(&tree);
179        assert_eq!(
180            state.focused.as_ref().map(|t| t.key.as_str()),
181            Some("b"),
182            "focus should survive scroll-out clipping",
183        );
184    }
185
186    /// When the focused node is genuinely removed from the tree (e.g.
187    /// the page that hosted it unmounted), focus is cleared. Mirrors
188    /// HTML's behavior of blurring an element that's removed from the
189    /// document.
190    #[test]
191    fn focused_node_removed_from_tree_clears_focus() {
192        use crate::tree::*;
193        let mut tree = crate::column([crate::widgets::button::button("a").key("a")]);
194        let mut state = UiState::new();
195        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
196        state.sync_focus_order(&tree);
197        let target = state
198            .focus
199            .order
200            .iter()
201            .find(|t| t.key == "a")
202            .cloned()
203            .expect("a in focus order");
204        state.set_focus(Some(target));
205        assert_eq!(state.focused.as_ref().map(|t| t.key.as_str()), Some("a"));
206        // Replace the tree with an empty one — the previously focused
207        // node is gone.
208        let mut empty = crate::column(Vec::<El>::new());
209        layout(&mut empty, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
210        state.sync_focus_order(&empty);
211        assert!(
212            state.focused.is_none(),
213            "focus should clear when the node leaves the tree",
214        );
215    }
216}