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}