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