teksilo-core 0.16.0

Core of the Teksilo GUI framework — widget trait, arena, layout engine, event dispatch, focus, signals and theming.
Documentation
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
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! The framework's announcer, as a platform adapter receives it.
//!
//! Every update is replayed through `accesskit_consumer`, the crate all three
//! adapters are built on, with the rule `accesskit_atspi_common` uses to decide
//! that a node entered or left the tree a reader sees (`adapter.rs:280-349`):
//! a node that starts to pass `common_filter` is added, and one that stops is
//! removed, which AT-SPI announces as defunct. What these tests hold the
//! announcer to is what `tools/reader/` measured Orca 46.1 needing (the
//! `announcer-calendar-arrows` scenario):
//!
//! - a message spoken from a node the adapter had once removed came from an
//!   object Orca's AT-SPI library still held for dead, and was dropped;
//! - a message whose node was gone by the time Orca asked it for its role was
//!   dropped too, and Orca asked tens of milliseconds after the event, which
//!   was already after a node that lived one update.

use std::collections::HashSet;
use std::time::Duration;

use accesskit::NodeId;
use accesskit_consumer::{FilterResult, NodeRef, Tree, TreeChangeHandler, common_filter};
use teksilo_canvas::SizeProposal;

use crate::announcer::Politeness;
use crate::test_widgets::FillWidget;
use crate::widget_tree::WidgetTree;

/// A tree with one widget in it, laid out and synced once.
fn settled() -> WidgetTree {
    let mut tree = WidgetTree::new();
    tree.add(FillWidget::new());
    tree.layout(SizeProposal::exact(400.0, 300.0));
    tree
}

/// A platform adapter's view of the tree: which nodes it has created, which it
/// has removed, and which live nodes it announced as they arrived.
struct Adapter {
    replay: Tree,
    /// Every node the adapter has ever removed, and so announced as defunct.
    defunct: HashSet<NodeId>,
}

#[derive(Default)]
struct Changes {
    /// Live nodes that entered, with their name, in no particular order.
    announced: Vec<(NodeId, String)>,
    removed: Vec<NodeId>,
    /// Whether the update moved focus. The consumer hands an update's added
    /// and changed nodes to the adapter first and the focus move after them
    /// (`accesskit_consumer` `tree.rs:640-673`), so everything in `announced`
    /// reached the adapter before this focus event.
    focus_moved: bool,
}

fn included(node: &NodeRef) -> bool {
    common_filter(node) == FilterResult::Include
}

impl Changes {
    fn entered(&mut self, node: &NodeRef) {
        if node.live() != accesskit::Live::Off
            && let Some(name) = node.label()
        {
            self.announced.push((node.locate().0, name));
        }
    }
}

impl TreeChangeHandler for Changes {
    fn node_added(&mut self, node: &NodeRef) {
        if included(node) {
            self.entered(node);
        }
    }

    fn node_updated(&mut self, old: &NodeRef, new: &NodeRef) {
        match (included(old), included(new)) {
            (false, true) => self.entered(new),
            (true, false) => self.removed.push(old.locate().0),
            _ => {}
        }
    }

    fn focus_moved(&mut self, _old: Option<&NodeRef>, _new: Option<&NodeRef>) {
        self.focus_moved = true;
    }

    fn node_removed(&mut self, node: &NodeRef) {
        if included(node) {
            self.removed.push(node.locate().0);
        }
    }
}

impl Adapter {
    fn attach(tree: &mut WidgetTree) -> Self {
        Self {
            replay: Tree::new(tree.sync_accessibility(), true),
            defunct: HashSet::new(),
        }
    }

    /// Run one frame, as `teksilo-app` does (lay out, then sync), and report
    /// what the adapter did with the update.
    fn sync(&mut self, tree: &mut WidgetTree) -> Changes {
        tree.layout(SizeProposal::exact(400.0, 300.0));
        let mut changes = Changes::default();
        self.replay
            .update_and_process_changes(tree.sync_accessibility(), &mut changes);
        changes
    }

    /// How many announcer nodes the adapter has in the tree now.
    fn announcer_nodes(&self) -> usize {
        self.replay
            .state()
            .root()
            .children()
            .filter(|child| {
                crate::announcer::is_announcer_node(child.locate().0) && included(child)
            })
            .count()
    }

    /// Whether a live node named `text` is in the tree a reader walks now.
    fn holds(&self, text: &str) -> bool {
        fn walk(node: NodeRef<'_>, text: &str) -> bool {
            (node.live() != accesskit::Live::Off && node.label().as_deref() == Some(text))
                || node
                    .filtered_children(&common_filter)
                    .any(|child| walk(child, text))
        }
        walk(self.replay.state().root(), text)
    }
}

/// The defect `tools/reader/` found in every Teksilo application: after the
/// first message of a session, every message came from a node the adapter
/// had already removed, and Orca dropped it as defunct.
#[test]
fn every_message_comes_from_a_node_the_platform_has_never_had() {
    let mut tree = settled();
    let mut adapter = Adapter::attach(&mut tree);
    // The same text twice on purpose: a repeat is the case the announcer
    // exists for, and it must still be a new node.
    for message in ["Saved", "Saved", "Deleted", "Saved"] {
        let removed_before = adapter.defunct.clone();
        tree.announce(message);
        let mut spoken = Vec::new();
        // Enough frames for any cycle of showing and taking away, then enough
        // time for any node the message was spoken from to have left.
        for _ in 0..4 {
            let changes = adapter.sync(&mut tree);
            spoken.extend(changes.announced);
            adapter.defunct.extend(changes.removed);
        }
        tree.advance_time(Duration::from_secs(10));
        for _ in 0..2 {
            let changes = adapter.sync(&mut tree);
            spoken.extend(changes.announced);
            adapter.defunct.extend(changes.removed);
        }
        let from: Vec<NodeId> = spoken
            .iter()
            .filter(|(_, text)| text == message)
            .map(|(id, _)| *id)
            .collect();
        assert_eq!(
            from.len(),
            1,
            "{message:?} must be announced once: {spoken:?}"
        );
        assert!(
            !removed_before.contains(&from[0]),
            "{message:?} was spoken from {:?}, a node the adapter had already removed \
             and announced as defunct; Orca drops what it says",
            from[0]
        );
        // Its own node is removed now, and never to be used again.
        assert!(
            adapter.defunct.contains(&from[0]),
            "{message:?}'s node must have left the tree once its time was over"
        );
    }
}

/// The other half of what Orca needed: a message's node must still be there
/// when a reader asks it for its role, which is after the event, not at it.
#[test]
fn a_message_stays_in_the_tree_until_a_reader_can_ask_for_it() {
    let mut tree = settled();
    let mut adapter = Adapter::attach(&mut tree);
    tree.advance_time(Duration::ZERO);
    tree.announce("Event added");
    let first = adapter.sync(&mut tree);
    assert!(
        first
            .announced
            .iter()
            .any(|(_, text)| text == "Event added"),
        "the message must be announced on the first update after it is asked for"
    );
    // The next frames, a few milliseconds later each.
    for frame in 1..=3 {
        tree.advance_time(Duration::from_millis(16));
        let _ = adapter.sync(&mut tree);
        assert!(
            adapter.holds("Event added"),
            "frame {frame}: the message's node left the tree before a reader could \
             ask for it; Orca answers \"Unknown object\" and drops the announcement"
        );
    }
    // A second later, far past anything a reader took to ask.
    tree.advance_time(Duration::from_secs(1));
    let _ = adapter.sync(&mut tree);
    assert!(
        adapter.holds("Event added"),
        "a second on, the node must still be there"
    );
    // And long after, it has gone: nobody walking the window later meets it.
    tree.advance_time(Duration::from_secs(10));
    let _ = adapter.sync(&mut tree);
    assert!(
        !adapter.holds("Event added"),
        "ten seconds on, the message must have left the tree"
    );
}

/// Two messages asked for together are spoken in the order they were asked
/// for, and the first is still there to be read when the second arrives.
#[test]
fn a_second_message_does_not_take_the_first_away() {
    let mut tree = settled();
    let mut adapter = Adapter::attach(&mut tree);
    tree.advance_time(Duration::ZERO);
    tree.announce("Moved to position 3");
    tree.announce("Moved to position 4");
    let mut order = Vec::new();
    for _ in 0..4 {
        let changes = adapter.sync(&mut tree);
        order.extend(changes.announced.into_iter().map(|(_, text)| text));
        if order.len() == 2 {
            break;
        }
        tree.advance_time(Duration::from_millis(16));
    }
    assert_eq!(order, ["Moved to position 3", "Moved to position 4"]);
    assert!(
        adapter.holds("Moved to position 3"),
        "the first message left the tree as the second arrived, before a reader \
         busy with the second could ask the first for its role"
    );
}

/// A spoken message leaves the tree by itself: the tree asks the event loop to
/// wake when it is due to go, and does not spin frames until then.
#[test]
fn a_spoken_message_wakes_the_loop_once_to_leave() {
    let mut tree = settled();
    let mut adapter = Adapter::attach(&mut tree);
    tree.announce_with("Upload failed", Politeness::Assertive);
    let _ = adapter.sync(&mut tree);
    let _ = adapter.sync(&mut tree);
    let now = std::time::Instant::now();
    let deadline = tree
        .next_timer_deadline()
        .expect("a message in the tree must ask the loop to wake when it leaves");
    assert!(
        deadline > now + Duration::from_secs(1),
        "the loop is woken when the message leaves, not every frame until then \
         (deadline {:?} from now)",
        deadline.saturating_duration_since(now)
    );
}

/// A burst of messages does not grow the tree without bound.
#[test]
fn a_burst_of_messages_keeps_the_tree_small() {
    let mut tree = settled();
    let mut adapter = Adapter::attach(&mut tree);
    tree.advance_time(Duration::ZERO);
    for i in 0..30 {
        tree.announce(format!("Row {i}"));
    }
    let mut heard = Vec::new();
    for _ in 0..80 {
        let changes = adapter.sync(&mut tree);
        heard.extend(changes.announced.into_iter().map(|(_, text)| text));
        let live = adapter.announcer_nodes();
        assert!(live <= 8, "{live} announcer nodes in the tree at once");
        tree.advance_time(Duration::from_millis(16));
    }
    let expected: Vec<String> = (0..30).map(|i| format!("Row {i}")).collect();
    assert_eq!(heard, expected, "every message is heard, in order");
}

/// What a reader makes of `message` over the frames after it is asked for,
/// one entry each time an adapter raises it: `"heard"`, or `"cut"` when the
/// same update also moved focus. The adapter raises that update's
/// announcements before its focus event, and Orca 46.1 stops speaking to read
/// a new focus (`locusOfFocusChanged` calls `presentationInterrupt`,
/// `orca/scripts/default.py:698-705`), which `tools/reader/` measured cutting
/// every such message.
fn fate(adapter: &mut Adapter, tree: &mut WidgetTree, message: &str) -> Vec<&'static str> {
    let mut fate = Vec::new();
    for _ in 0..4 {
        let changes = adapter.sync(tree);
        for (_, text) in &changes.announced {
            if text == message {
                fate.push(if changes.focus_moved { "cut" } else { "heard" });
            }
        }
        tree.advance_time(Duration::from_millis(16));
    }
    fate
}

/// The defect `tools/reader/` found behind every keyboard move of a row or a
/// tab: the handler that moves the item announces where it went and puts
/// focus on it, both reach the adapter in one update, and the announcement
/// comes first, so Orca cut it for the focus. Here focus moves from one
/// control to another, as it does to a rebuilt tab header.
#[test]
fn a_message_raised_as_focus_moves_is_heard_after_the_move() {
    let mut tree = WidgetTree::new();
    let first = tree.add(FillWidget::new().focusable());
    let second = tree.add(FillWidget::new().focusable());
    tree.layout(SizeProposal::exact(400.0, 300.0));
    tree.focus(first);
    let mut adapter = Adapter::attach(&mut tree);
    tree.advance_time(Duration::ZERO);

    // One handler: the item moves, focus follows it, and the move is said.
    tree.focus(second);
    tree.announce("Doc 1 moved to 5 of 6");
    assert_eq!(
        fate(&mut adapter, &mut tree, "Doc 1 moved to 5 of 6"),
        ["heard"],
        "the message reached the adapter in the update that moved focus, ahead \
         of the focus event, and Orca stops speaking to read the new focus"
    );
}

/// A list that keeps focus itself and points `active_descendant` at its
/// current row, as `ListView`, `TreeView` and `GridView` do. The consumer
/// resolves focus through it (`accesskit_consumer` `tree.rs:537-543`), so a
/// new current row is a focus move to every adapter although the focused
/// widget is the same.
#[derive(Debug)]
struct Roving {
    rows: Vec<crate::widget_id::WidgetId>,
    current: std::rc::Rc<std::cell::Cell<usize>>,
}

impl crate::widget::Widget for Roving {
    fn build(
        &mut self,
        ctx: &mut crate::build_context::BuildContext,
    ) -> Vec<crate::widget_id::WidgetId> {
        ctx.apply_self_handlers(crate::widget_builder::HandlerSet::new().focusable(true));
        self.rows = (1..=3)
            .map(|i| ctx.add(FillWidget::new().label(format!("Item {i}"))))
            .collect();
        self.rows.clone()
    }

    fn layout_response(
        &self,
        proposal: SizeProposal,
        _ctx: &crate::widget::LayoutContext,
    ) -> crate::widget::LayoutResponse {
        proposal.resolve(0.0, 0.0).into()
    }

    fn children(&self) -> Vec<crate::widget_id::WidgetId> {
        self.rows.clone()
    }

    fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
        builder.set_role(accesskit::Role::ListBox);
        if let Some(&row) = self.rows.get(self.current.get()) {
            builder.set_active_descendant(crate::accessibility::widget_id_to_node_id(row));
        }
    }
}

/// The same, where focus stays on the list and its current row changes: the
/// `ListView` and `TreeView` reorder, whose moved row is a new node.
#[test]
fn a_message_raised_as_the_current_row_changes_is_heard_after_the_change() {
    let current = std::rc::Rc::new(std::cell::Cell::new(0));
    let mut tree = WidgetTree::new();
    let list = tree.add(Roving {
        rows: Vec::new(),
        current: current.clone(),
    });
    tree.layout(SizeProposal::exact(400.0, 300.0));
    tree.focus(list);
    let mut adapter = Adapter::attach(&mut tree);
    tree.advance_time(Duration::ZERO);

    current.set(1);
    tree.request_accessibility_update();
    tree.announce("Moved to 2 of 3");
    assert_eq!(
        fate(&mut adapter, &mut tree, "Moved to 2 of 3"),
        ["heard"],
        "the message reached the adapter in the update that moved the current \
         row, ahead of the focus event, and Orca stops speaking to read the row"
    );
}

/// A message raised while focus stays where it is goes out at once: holding
/// one back is for an update that moves focus, and costs nothing otherwise.
#[test]
fn a_message_raised_while_focus_stays_is_not_held() {
    let mut tree = WidgetTree::new();
    let only = tree.add(FillWidget::new().focusable());
    tree.layout(SizeProposal::exact(400.0, 300.0));
    tree.focus(only);
    let mut adapter = Adapter::attach(&mut tree);
    tree.advance_time(Duration::ZERO);

    tree.announce("Saved");
    let changes = adapter.sync(&mut tree);
    assert_eq!(
        changes
            .announced
            .iter()
            .map(|(_, text)| text.as_str())
            .collect::<Vec<_>>(),
        ["Saved"],
        "nothing moved focus, so the first update speaks the message"
    );
}

/// Focus that moves on two frames running, as a rebuild that settles over two
/// layouts can make it, holds the message until it has stopped: a message
/// raised with the first move is heard after the second.
#[test]
fn a_message_waits_for_focus_to_stop_moving() {
    let mut tree = WidgetTree::new();
    let first = tree.add(FillWidget::new().focusable());
    let second = tree.add(FillWidget::new().focusable());
    let third = tree.add(FillWidget::new().focusable());
    tree.layout(SizeProposal::exact(400.0, 300.0));
    tree.focus(first);
    let mut adapter = Adapter::attach(&mut tree);
    tree.advance_time(Duration::ZERO);

    tree.focus(second);
    tree.announce("Moved to 3 of 10");
    let changes = adapter.sync(&mut tree);
    assert!(changes.focus_moved, "the first frame moves focus");
    assert!(
        changes.announced.is_empty(),
        "the message went out with the first move, ahead of its focus event: {:?}",
        changes.announced
    );
    tree.advance_time(Duration::from_millis(16));
    tree.focus(third);
    assert_eq!(
        fate(&mut adapter, &mut tree, "Moved to 3 of 10"),
        ["heard"],
        "the message went out with the second move, ahead of its focus event"
    );
}