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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
//! Arrow-key motion inside a composite:
//! a tab list, a radio
//! group, a menu or a picker list is one Tab stop, and these move focus
//! within it — arrows, Home/End, type-ahead, and the slider nudge.
use super::*;
impl Core {
// -- Composites (docs/adr/0007-composite-keyboard-patterns.md) ------
// A tab list, a radio group, a menu and a picker list are one Tab stop
// with the arrows moving inside. The core moves that focus itself,
// because focus is core state and this is the motion Tab already
// performs with a narrower scope — every app in every binding would
// otherwise reimplement the same ordered walk over a tree the core
// computes and does not expose, and a Lua app could not do it at all.
// It never writes `selected`: that is app state, re-declared each
// frame, so a view that wants selection to follow focus writes
// `selected(ui.is_focused(key))`.
/// One arrow, Home or End step inside the composite holding node `i`.
/// Nothing happens when `i` is not a composite item, or when the step
/// runs off the end of one that clamps.
pub(crate) fn composite_step(&mut self, i: usize, ek: EditKey, out: &mut Vec<UiEvent>) {
let mut items = std::mem::take(&mut self.items_scratch);
let target = self.composite_target(i, ek, &mut items);
self.items_scratch = items;
if let Some((j, item)) = target {
self.move_within_composite(i, j, item, out);
}
}
/// Lands focus on item `j` of a composite, having come from `i`, and
/// activates it where the pattern says selection follows focus. The
/// landing is exactly `focus_next`'s: focus moves, it shows, and the
/// item scrolls into view.
fn move_within_composite(
&mut self,
i: usize,
j: usize,
item: crate::access::Role,
out: &mut Vec<UiEvent>,
) {
self.focus_visible = true;
if j == i {
// A clamped End on the last item, or a search that matched
// where focus already is: the motion happened, so the focus
// shows, but nothing moved and nothing is activated again.
return;
}
self.land_focus(j);
// `radio` and `tab` define selection as following focus, and the
// event is the one Enter already emits — so an app that handles
// clicks on its tabs handles arrows on them with no new code.
if crate::composite::activates_on_motion(item) {
self.click_node(self.tree.keys[j], out);
}
}
/// Where a step from item `i` lands, with the item role it lands on.
/// Fills `items` with the composite's items on the way (see
/// `composite::items`).
fn composite_target(
&self,
i: usize,
ek: EditKey,
items: &mut Vec<usize>,
) -> Option<(usize, crate::access::Role)> {
let container = crate::composite::owner(&self.tree, i, items)?;
let role = self.tree.specs[container].access().role?;
let item = crate::composite::item_role(role)?;
// A disabled item is not an item here, as it is not in the ring —
// it keeps its ordinal in "3 of 7" and the arrows step over it.
let live: Vec<usize> = items
.iter()
.copied()
.filter(|&j| crate::access::focusable(&self.tree, j))
.collect();
let at = live.iter().position(|&j| j == i)?;
let wrap = crate::composite::wraps(role);
let layout = self.tree.specs[container].layout;
// The one place the two arrow pairs differ: in a wrapped container
// the cross-axis pair moves by a line rather than by one item,
// which is what a wrapped grid of items needs. Wrapping is
// rows-only, so the cross pair is always Up / Down.
let by_line = layout.wrap && layout.dir == crate::spec::Dir::Row;
let j = match ek {
EditKey::Home => live[0],
EditKey::End => live[live.len() - 1],
EditKey::Up | EditKey::Down if by_line => {
let down = ek == EditKey::Down;
live[self.line_step(container, &live, at, down, wrap)?]
}
// Both pairs move, whatever the derived orientation says: the
// perpendicular pair costs nothing, while refusing it turns a
// mis-derived axis into a keyboard dead end that only a screen
// reader user finds.
EditKey::Left | EditKey::Up => live[step(at, -1, live.len(), wrap)?],
EditKey::Right | EditKey::Down => live[step(at, 1, live.len(), wrap)?],
_ => return None,
};
Some((j, item))
}
/// A cross-axis step in a wrapped container: to the adjacent wrap line,
/// keeping the position within the line (clamped to that line's
/// length). Returns an index into `live`.
fn line_step(
&self,
container: usize,
live: &[usize],
at: usize,
down: bool,
wrap: bool,
) -> Option<usize> {
let lines: Vec<u32> = live
.iter()
.map(|&j| crate::composite::line_of(&self.tree, container, j))
.collect();
let cur = lines[at];
let col = lines[..at].iter().filter(|&&l| l == cur).count();
let next = if down {
lines.iter().copied().filter(|&l| l > cur).min()
} else {
lines.iter().copied().filter(|&l| l < cur).max()
};
let target = match next {
Some(l) => l,
None if wrap => {
let l = if down {
lines.iter().min()
} else {
lines.iter().max()
};
*l?
}
None => return None,
};
let row: Vec<usize> = (0..live.len()).filter(|&k| lines[k] == target).collect();
row.get(col.min(row.len().checked_sub(1)?)).copied()
}
/// One printable keystroke inside a composite: extends the search
/// buffer and moves focus to the next item whose name starts with it,
/// wrapping. Returns whether the composite took the text — `false`
/// leaves Space to press the item, which is what it does with no
/// search under way.
pub(crate) fn type_ahead(&mut self, i: usize, text: &str, out: &mut Vec<UiEvent>) -> bool {
let typed: String = text.chars().filter(|c| !c.is_control()).collect();
if typed.is_empty() || (typed == " " && self.type_ahead.is_empty()) {
return false;
}
let mut items = std::mem::take(&mut self.items_scratch);
let found = self.type_ahead_target(i, &typed, &mut items);
self.items_scratch = items;
match found {
Some((j, item)) => {
self.move_within_composite(i, j, item, out);
true
}
// Not a composite item: the text is not ours, so Space still
// presses. A search that matched nothing *is* ours — the
// buffer holds it, and the next character extends it rather
// than starting over.
None => !self.type_ahead.is_empty(),
}
}
/// The item the search buffer, extended by `typed`, now names.
fn type_ahead_target(
&mut self,
i: usize,
typed: &str,
items: &mut Vec<usize>,
) -> Option<(usize, crate::access::Role)> {
let container = crate::composite::owner(&self.tree, i, items)?;
let item = crate::composite::item_role(self.tree.specs[container].access().role?)?;
// With no clock every keystroke starts a fresh search: the buffer
// has no way to age, and a stale one would be worse than none.
let now = self.anim.time();
if now.is_none() {
self.type_ahead.clear();
}
self.type_ahead.push_str(&typed.to_lowercase());
self.type_ahead_at = now;
let live: Vec<usize> = items
.iter()
.copied()
.filter(|&j| crate::access::focusable(&self.tree, j))
.collect();
let at = live.iter().position(|&j| j == i)?;
let names = self.item_names(&live);
let buf = self.type_ahead.clone();
// From the item after the focused one, wrapping — so the focused
// item is the last one tried, which is what makes a second
// character refine the match instead of jumping off it.
(1..=live.len()).find_map(|d| {
let k = (at + d) % live.len();
let name = names[k].as_deref()?;
name.to_lowercase()
.starts_with(&buf)
.then_some((live[k], item))
})
}
/// What a reader announces for each of `items`, in order — which is
/// what type-ahead searches.
///
/// Usually the access name, but not every item role has one: `tab`,
/// `radio` and `menuItem` are named by the text inside them, while a
/// `listItem` is a *container* of content and takes no name from it
/// (giving a row a label as well would have it read twice). So a row
/// falls back to the text a reader reads out for it — its own
/// `staticText` descendants, in order — and a picker list is
/// searchable without every row having to carry a `label`.
fn item_names(&mut self, items: &[usize]) -> Vec<Option<String>> {
let keys: Vec<Key> = items.iter().map(|&j| self.tree.keys[j]).collect();
let tree = self.access_tree();
keys.iter()
.map(|k| {
let at = tree.nodes.iter().position(|n| n.key == *k)?;
if let Some(name) = &tree.nodes[at].name {
return Some(name.clone());
}
// The access tree is preorder, so a node's descendants are
// the run that follows it.
let mut inside = vec![*k];
let mut text = String::new();
for n in &tree.nodes[at + 1..] {
if !n.parent.is_some_and(|p| inside.contains(&p)) {
break;
}
inside.push(n.key);
if n.role == crate::access::Role::StaticText
&& let Some(t) = &n.name
{
if !text.is_empty() {
text.push(' ');
}
text.push_str(t);
}
}
(!text.is_empty()).then_some(text)
})
.collect()
}
/// Clears a type-ahead buffer that has gone stale. Run at the start of
/// a frame, which is where the clock already is: input routing stays
/// timeless, and a frame with nothing typed pays one comparison.
pub(crate) fn age_type_ahead(&mut self) {
if self.type_ahead.is_empty() {
return;
}
if let (Some(now), Some(at)) = (self.anim.time(), self.type_ahead_at)
&& now - at > TYPE_AHEAD_SECS
{
self.type_ahead.clear();
self.type_ahead_at = None;
}
}
/// A slider move on node `i`. A slider that declared `on_change` has
/// the core do the arithmetic: the move is
/// worked from its declared `value_now`, range and step, and proposed
/// as `{kind="change", value, phase="end", tag}` — nothing when it
/// lands where the slider already is. Any other slider reaches the app
/// as `{kind="access", action, tag}`, since the core cannot know what
/// a step means there, and only for a single step: its Page, Home and
/// End keys were never its own.
pub(crate) fn nudge(
&mut self,
i: usize,
mv: crate::slider::SliderMove,
out: &mut Vec<UiEvent>,
) {
use crate::slider::{SliderMove, SliderRange, change_event};
let spec = &self.tree.specs[i];
if spec.disabled {
return;
}
if let Some(tag) = spec.events().on_change.as_ref() {
let ax = spec.access();
let Some(range) = SliderRange::of(ax) else {
return;
};
let value = range.moved(ax.value_now, mv);
if ax.value_now.map(crate::slider::exact) != Some(value) {
out.push(change_event(
self.tree.origins[i],
self.tree.keys[i],
value,
"end",
tag,
));
}
return;
}
let action = match mv {
SliderMove::Step(n) if n > 0 => crate::access::AccessAction::Increment,
SliderMove::Step(_) => crate::access::AccessAction::Decrement,
_ => return,
};
let payload = Value::map([
("kind", Value::str("access")),
("action", Value::str(action.name())),
]);
out.push(
UiEvent::on(self.tree.origins[i], self.tree.keys[i], payload)
.tagged(self.access_tag(i).as_ref()),
);
}
/// A reader's `SetValue` on slider `i`: `value` as the number it
/// asked for. Windows' UI Automation moves a slider
/// only this way — its RangeValue pattern has no increment — and the
/// request was dropped, so a UIA client read a writable slider whose
/// writes did nothing. With `on_change` the core proposes `value`
/// snapped to the step and clamped, as the pointer does; without, the
/// app hears `{kind="access", action="setValue", value}` beside the
/// increments it already hears. Text that is not a number — the
/// Value pattern handing over a `value_text` such as "25 minutes" —
/// is nothing.
pub(crate) fn set_slider(&mut self, i: usize, value: &str, out: &mut Vec<UiEvent>) {
use crate::slider::{SliderRange, change_event};
let spec = &self.tree.specs[i];
if spec.disabled || spec.access().role != Some(crate::access::Role::Slider) {
return;
}
let Some(asked) = value.trim().parse::<f64>().ok().filter(|v| v.is_finite()) else {
return;
};
if let Some(tag) = spec.events().on_change.as_ref() {
let ax = spec.access();
let Some(range) = SliderRange::of(ax) else {
return;
};
let value = range.snap(asked);
if ax.value_now.map(crate::slider::exact) != Some(value) {
out.push(change_event(
self.tree.origins[i],
self.tree.keys[i],
value,
"end",
tag,
));
}
return;
}
let payload = Value::map([
("kind", Value::str("access")),
(
"action",
Value::str(crate::access::AccessAction::SetValue.name()),
),
("value", Value::Float(asked)),
]);
out.push(
UiEvent::on(self.tree.origins[i], self.tree.keys[i], payload)
.tagged(self.access_tag(i).as_ref()),
);
}
}