1
//! Default Action Processing for Keyboard Events
2
//!
3
//! This module implements W3C-compliant default actions for keyboard events.
4
//! Default actions are built-in behaviors that occur after event dispatch,
5
//! unless `event.prevent_default()` was called.
6
//!
7
//! ## W3C Event Model
8
//!
9
//! Per DOM Level 2/3 and W3C UI Events:
10
//!
11
//! 1. Event is dispatched through capture → target → bubble phases
12
//! 2. Callbacks can call `event.prevent_default()` to cancel default action
13
//! 3. After dispatch, if not prevented, the default action is performed
14
//!
15
//! ## Keyboard Default Actions
16
//!
17
//! | Key | Modifiers | Default Action |
18
//! |-----|-----------|----------------|
19
//! | Tab | None | Focus next element |
20
//! | Tab | Shift | Focus previous element |
21
//! | Enter | None | Activate focused element (if activatable) |
22
//! | Space | None | Activate focused element (if activatable) |
23
//! | Escape | None | Clear focus |
24
//!
25
//! ## Activation Behavior (HTML5)
26
//!
27
//! Per HTML5 spec, elements with "activation behavior" can be activated via
28
//! Enter or Space. This generates a synthetic click event:
29
//!
30
//! - Button elements
31
//! - Anchor elements with href
32
//! - Input elements (submit, button, checkbox, radio)
33
//! - Any element with a click callback
34
//!
35
//! See: https://html.spec.whatwg.org/multipage/interaction.html#activation-behavior
36

            
37
use alloc::vec::Vec;
38
use azul_core::{
39
    callbacks::FocusTarget,
40
    dom::{DomId, DomNodeId, NodeId},
41
    events::{DefaultAction, DefaultActionResult, ScrollAmount, ScrollDirection},
42
    window::{KeyboardState, VirtualKeyCode},
43
};
44
use crate::window::DomLayoutResult;
45
use std::collections::BTreeMap;
46

            
47
/// Editing state of the focused node, as the caret sees it — built by the
48
/// caller (`LayoutWindow::build_editing_query_state`) because the decision
49
/// function itself deliberately cannot see cursors.
50
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
51
// Four independent facts about one caret position, not a state enum: any
52
// combination of them can hold at once.
53
#[allow(clippy::struct_excessive_bools)]
54
pub struct EditingQueryState {
55
    /// The focused node is inside a `contenteditable` host.
56
    pub is_contenteditable: bool,
57
    /// The caret sits at the very start of the block's text.
58
    pub cursor_at_block_start: bool,
59
    /// The caret sits at the very end of the block's text.
60
    pub cursor_at_block_end: bool,
61
    /// The editing HOST's computed `white-space` preserves newlines
62
    /// (pre / pre-wrap / break-spaces / pre-line). In such a host a literal
63
    /// `"\n"` is the native line separator, so Enter inserts one instead of
64
    /// recording a structural block split.
65
    pub host_preserves_newlines: bool,
66
}
67

            
68
9351
#[must_use] pub fn determine_keyboard_default_action(
69
9351
    keyboard_state: &KeyboardState,
70
9351
    focused_node: Option<DomNodeId>,
71
9351
    layout_results: &BTreeMap<DomId, DomLayoutResult>,
72
9351
    prevented: bool,
73
9351
) -> DefaultActionResult {
74
9351
    determine_keyboard_default_action_with_editing(
75
9351
        keyboard_state,
76
9351
        focused_node,
77
9351
        layout_results,
78
9351
        prevented,
79
9351
        None,
80
    )
81
9351
}
82

            
83
/// [`determine_keyboard_default_action`] with contenteditable awareness.
84
///
85
/// Enter on a contenteditable focus records a structural SPLIT (Shift+Enter
86
/// stays a soft line break through the text path); Backspace at block start /
87
/// Delete at block end record structural MERGES. `editing: None` behaves
88
/// exactly like the editing-blind variant.
89
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
90
9381
#[must_use] pub fn determine_keyboard_default_action_with_editing(
91
9381
    keyboard_state: &KeyboardState,
92
9381
    focused_node: Option<DomNodeId>,
93
9381
    layout_results: &BTreeMap<DomId, DomLayoutResult>,
94
9381
    prevented: bool,
95
9381
    editing: Option<&EditingQueryState>,
96
9381
) -> DefaultActionResult {
97
    // If prevented, return early with no action
98
9381
    if prevented {
99
1081
        return DefaultActionResult::prevented();
100
8300
    }
101

            
102
    // Get the current key (if any)
103
8300
    let Some(current_key) = keyboard_state.current_virtual_keycode.into_option() else {
104
1
        return DefaultActionResult::default();
105
    };
106

            
107
    // Check modifier state
108
8299
    let shift_down = keyboard_state.shift_down();
109
8299
    let ctrl_down = keyboard_state.ctrl_down();
110
8299
    let alt_down = keyboard_state.alt_down();
111

            
112
    // Determine action based on key
113
8299
    let action = match current_key {
114
        // Tab navigation
115
        VirtualKeyCode::Tab => {
116
476
            if ctrl_down || alt_down {
117
                // Ctrl+Tab / Alt+Tab are typically handled by OS
118
321
                DefaultAction::None
119
155
            } else if shift_down {
120
95
                DefaultAction::FocusPrevious
121
            } else {
122
60
                DefaultAction::FocusNext
123
            }
124
        }
125

            
126
        // Activation (Enter key)
127
        VirtualKeyCode::Return | VirtualKeyCode::NumpadEnter => {
128
931
            focused_node.as_ref().map_or(DefaultAction::None, |focus| {
129
                // Enter in a contenteditable host: a STRUCTURAL block split
130
                // for the app to apply (the browser default) — unless the
131
                // host is a plain-text context (`white-space` preserves
132
                // newlines), where `"\n"` is the native separator and the
133
                // split has no app model to land in. Shift+Enter is the soft
134
                // break in either kind of host.
135
850
                if let Some(e) = editing.filter(|e| e.is_contenteditable) {
136
12
                    if shift_down || e.host_preserves_newlines {
137
2
                        return DefaultAction::InsertLineBreakAtCursor { target: *focus };
138
10
                    }
139
10
                    return DefaultAction::SplitBlockAtCursor { target: *focus };
140
838
                }
141
838
                if is_element_activatable(focus, layout_results) {
142
109
                    DefaultAction::ActivateFocusedElement {
143
109
                        target: *focus,
144
109
                    }
145
                } else {
146
                    // Enter on non-activatable element - might submit form
147
                    // For now, no action (form handling could be added later)
148
729
                    DefaultAction::None
149
                }
150
850
            })
151
        }
152

            
153
        // Backspace at BLOCK START / Delete at BLOCK END in a contenteditable
154
        // host: structural block merges. Anywhere else these keys keep flowing
155
        // through the per-IFC text-edit path unchanged (this fn returns None).
156
        VirtualKeyCode::Back => {
157
2
            match (focused_node.as_ref(), editing) {
158
1
                (Some(focus), Some(e))
159
2
                    if e.is_contenteditable && e.cursor_at_block_start =>
160
                {
161
1
                    DefaultAction::MergeWithPrevious { target: *focus }
162
                }
163
1
                _ => DefaultAction::None,
164
            }
165
        }
166
        VirtualKeyCode::Delete => {
167
2
            match (focused_node.as_ref(), editing) {
168
2
                (Some(focus), Some(e)) if e.is_contenteditable && e.cursor_at_block_end => {
169
1
                    DefaultAction::MergeWithNext { target: *focus }
170
                }
171
1
                _ => DefaultAction::None,
172
            }
173
        }
174

            
175
        // Activation (Space key) — or page-scroll when nothing activatable
176
        // has focus (MWA-C-scroll: the browser default; Shift+Space pages up).
177
        VirtualKeyCode::Space => {
178
467
            match focused_node.as_ref() {
179
31
                Some(focus)
180
425
                    if is_element_activatable(focus, layout_results)
181
52
                        && !is_text_input(focus, layout_results) =>
182
                {
183
31
                    DefaultAction::ActivateFocusedElement { target: *focus }
184
                }
185
                // Space in text input should insert space (handled by text input system)
186
394
                Some(focus) if is_text_input(focus, layout_results) => DefaultAction::None,
187
                _ => DefaultAction::ScrollFocusedContainer {
188
394
                    direction: if shift_down {
189
197
                        ScrollDirection::Up
190
                    } else {
191
197
                        ScrollDirection::Down
192
                    },
193
394
                    amount: ScrollAmount::Page,
194
                },
195
            }
196
        }
197

            
198
        // Escape - clear focus
199
        VirtualKeyCode::Escape => {
200
459
            if focused_node.is_some() {
201
418
                DefaultAction::ClearFocus
202
            } else {
203
                // Could close modal/dialog here if any is open
204
41
                DefaultAction::None
205
            }
206
        }
207

            
208
        // Arrow keys - scroll or navigate
209
        VirtualKeyCode::Up | VirtualKeyCode::Down | VirtualKeyCode::Left | VirtualKeyCode::Right => {
210
1824
            let direction = match current_key {
211
456
                VirtualKeyCode::Up => ScrollDirection::Up,
212
456
                VirtualKeyCode::Down => ScrollDirection::Down,
213
456
                VirtualKeyCode::Left => ScrollDirection::Left,
214
456
                _ => ScrollDirection::Right,
215
            };
216
            // MWA-C-scroll: arrows scroll with NO focused node too (the
217
            // consumer anchors on the hovered container then) — only a
218
            // focused text input claims the arrows for caret movement.
219
1824
            focused_node.as_ref().map_or(
220
1824
                DefaultAction::ScrollFocusedContainer {
221
1824
                    direction,
222
1824
                    amount: ScrollAmount::Line,
223
1824
                },
224
1660
                |focus| {
225
1660
                    if is_text_input(focus, layout_results) {
226
164
                        DefaultAction::None
227
                    } else {
228
1496
                        DefaultAction::ScrollFocusedContainer {
229
1496
                            direction,
230
1496
                            amount: ScrollAmount::Line,
231
1496
                        }
232
                    }
233
1660
                },
234
            )
235
        }
236

            
237
        // Page Up/Down
238
        VirtualKeyCode::PageUp => {
239
480
            DefaultAction::ScrollFocusedContainer {
240
480
                direction: ScrollDirection::Up,
241
480
                amount: ScrollAmount::Page,
242
480
            }
243
        }
244
        VirtualKeyCode::PageDown => {
245
481
            DefaultAction::ScrollFocusedContainer {
246
481
                direction: ScrollDirection::Down,
247
481
                amount: ScrollAmount::Page,
248
481
            }
249
        }
250

            
251
        // Home/End
252
        VirtualKeyCode::Home => {
253
457
            if ctrl_down {
254
                // Ctrl+Home - go to start of document
255
185
                DefaultAction::FocusFirst
256
            } else {
257
272
                DefaultAction::ScrollFocusedContainer {
258
272
                    direction: ScrollDirection::Up,
259
272
                    amount: ScrollAmount::Document,
260
272
                }
261
            }
262
        }
263
        VirtualKeyCode::End => {
264
455
            if ctrl_down {
265
                // Ctrl+End - go to end of document
266
183
                DefaultAction::FocusLast
267
            } else {
268
272
                DefaultAction::ScrollFocusedContainer {
269
272
                    direction: ScrollDirection::Down,
270
272
                    amount: ScrollAmount::Document,
271
272
                }
272
            }
273
        }
274

            
275
        // All other keys - no default action
276
2265
        _ => DefaultAction::None,
277
    };
278

            
279
8299
    DefaultActionResult::new(action)
280
9381
}
281

            
282
/// Check if an element is activatable (can receive synthetic click from Enter/Space).
283
1280
fn is_element_activatable(node_id: &DomNodeId, layout_results: &BTreeMap<DomId, DomLayoutResult>) -> bool {
284
1280
    let Some(layout) = layout_results.get(&node_id.dom) else {
285
668
        return false;
286
    };
287
612
    let Some(internal_id) = node_id.node.into_crate_internal() else {
288
64
        return false;
289
    };
290
548
    layout.styled_dom.node_data.as_container()
291
548
        .get(internal_id)
292
548
        .is_some_and(azul_core::dom::NodeData::is_activatable)
293
1280
}
294

            
295
/// Check if an element is a text input (where Space should insert text, not activate).
296
2122
fn is_text_input(node_id: &DomNodeId, layout_results: &BTreeMap<DomId, DomLayoutResult>) -> bool {
297
    use azul_core::events::{EventFilter, FocusEventFilter};
298
2122
    let Some(layout) = layout_results.get(&node_id.dom) else {
299
1109
        return false;
300
    };
301
1013
    let Some(internal_id) = node_id.node.into_crate_internal() else {
302
107
        return false;
303
    };
304
906
    let node_data = layout.styled_dom.node_data.as_container();
305
906
    let Some(node) = node_data.get(internal_id) else {
306
204
        return false;
307
    };
308

            
309
    // Check if this node has a TextInput callback (FocusEventFilter::TextInput)
310
    // which indicates it's a text input field
311
702
    node.get_callbacks()
312
702
        .iter()
313
702
        .any(|cb| matches!(cb.event, EventFilter::Focus(FocusEventFilter::TextInput)))
314
2122
}
315

            
316
/// Convert a `DefaultAction` to a `FocusTarget` for the focus manager.
317
///
318
/// This bridges the gap between the abstract `DefaultAction` and the
319
/// concrete `FocusTarget` that the `FocusManager` understands.
320
249
#[must_use] pub const fn default_action_to_focus_target(action: &DefaultAction) -> Option<FocusTarget> {
321
249
    match action {
322
16
        DefaultAction::FocusNext => Some(FocusTarget::Next),
323
5
        DefaultAction::FocusPrevious => Some(FocusTarget::Previous),
324
4
        DefaultAction::FocusFirst => Some(FocusTarget::First),
325
4
        DefaultAction::FocusLast => Some(FocusTarget::Last),
326
4
        DefaultAction::ClearFocus => Some(FocusTarget::NoFocus),
327
216
        _ => None,
328
    }
329
249
}
330

            
331
#[cfg(test)]
332
mod tests {
333
    use super::*;
334
    use azul_core::styled_dom::NodeHierarchyItemId;
335

            
336
    #[test]
337
    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
338
1
    fn test_tab_focus_next() {
339
1
        let mut keyboard_state = KeyboardState::default();
340
1
        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
341
        
342
1
        let result = determine_keyboard_default_action(
343
1
            &keyboard_state,
344
1
            None,
345
1
            &BTreeMap::new(),
346
            false,
347
        );
348
        
349
1
        assert!(matches!(result.action, DefaultAction::FocusNext));
350
1
        assert!(!result.prevented);
351
1
    }
352

            
353
    #[test]
354
    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
355
1
    fn test_shift_tab_focus_previous() {
356
1
        let mut keyboard_state = KeyboardState::default();
357
1
        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
358
        // Add LShift to pressed keys to simulate Shift being held
359
1
        keyboard_state.pressed_virtual_keycodes = vec![VirtualKeyCode::LShift, VirtualKeyCode::Tab].into();
360
        
361
1
        let result = determine_keyboard_default_action(
362
1
            &keyboard_state,
363
1
            None,
364
1
            &BTreeMap::new(),
365
            false,
366
        );
367
        
368
1
        assert!(matches!(result.action, DefaultAction::FocusPrevious));
369
1
    }
370

            
371
    #[test]
372
    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
373
1
    fn test_escape_clears_focus() {
374
1
        let mut keyboard_state = KeyboardState::default();
375
1
        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Escape).into();
376
        
377
1
        let focused = Some(DomNodeId {
378
1
            dom: DomId { inner: 0 },
379
1
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(1))),
380
1
        });
381
        
382
1
        let result = determine_keyboard_default_action(
383
1
            &keyboard_state,
384
1
            focused,
385
1
            &BTreeMap::new(),
386
            false,
387
        );
388
        
389
1
        assert!(matches!(result.action, DefaultAction::ClearFocus));
390
1
    }
391

            
392
    #[test]
393
    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
394
1
    fn test_prevented_returns_no_action() {
395
1
        let mut keyboard_state = KeyboardState::default();
396
1
        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
397
        
398
1
        let result = determine_keyboard_default_action(
399
1
            &keyboard_state,
400
1
            None,
401
1
            &BTreeMap::new(),
402
            true, // prevented!
403
        );
404

            
405
1
        assert!(result.prevented);
406
1
        assert!(matches!(result.action, DefaultAction::None));
407
1
    }
408
}
409

            
410
#[cfg(test)]
411
#[allow(clippy::field_reassign_with_default)] // KeyboardState is built incrementally in the helpers
412
mod autotest_generated {
413
    // ==================================================================
414
    // Structural-edit arms (A2): Enter splits, Backspace/Delete merge —
415
    // ONLY with contenteditable editing state; the editing-blind wrapper
416
    // must behave exactly as before.
417
    // ==================================================================
418

            
419
    #[test]
420
    fn enter_on_contenteditable_records_a_split_not_activation() {
421
        let layouts = BTreeMap::new();
422
        let focus = Some(dom_node(3));
423
        let editing = EditingQueryState {
424
            is_contenteditable: true,
425
            cursor_at_block_start: false,
426
            cursor_at_block_end: false,
427
            host_preserves_newlines: false,
428
        };
429

            
430
        let with = determine_keyboard_default_action_with_editing(
431
            &kbd(VirtualKeyCode::Return, &[]),
432
            focus,
433
            &layouts,
434
            false,
435
            Some(&editing),
436
        );
437
        assert!(matches!(
438
            with.action,
439
            DefaultAction::SplitBlockAtCursor { .. }
440
        ));
441

            
442
        // Shift+Enter: the soft break — a literal "\n" through the text
443
        // pipeline, never structural. (This used to fall through to nothing;
444
        // the documented intent is now implemented.)
445
        let shift = determine_keyboard_default_action_with_editing(
446
            &kbd(VirtualKeyCode::Return, &[VirtualKeyCode::LShift]),
447
            focus,
448
            &layouts,
449
            false,
450
            Some(&editing),
451
        );
452
        assert!(matches!(
453
            shift.action,
454
            DefaultAction::InsertLineBreakAtCursor { .. }
455
        ));
456

            
457
        // A PLAIN-TEXT host (white-space preserves newlines, e.g. the
458
        // text_area widget): plain Enter inserts the newline instead of
459
        // splitting blocks — there is no app model for a split to land in.
460
        let plaintext = EditingQueryState {
461
            host_preserves_newlines: true,
462
            ..editing
463
        };
464
        let plain_enter = determine_keyboard_default_action_with_editing(
465
            &kbd(VirtualKeyCode::Return, &[]),
466
            focus,
467
            &layouts,
468
            false,
469
            Some(&plaintext),
470
        );
471
        assert!(matches!(
472
            plain_enter.action,
473
            DefaultAction::InsertLineBreakAtCursor { .. }
474
        ));
475

            
476
        // Editing-blind: the old behavior, byte for byte.
477
        let without = determine_keyboard_default_action(
478
            &kbd(VirtualKeyCode::Return, &[]),
479
            focus,
480
            &layouts,
481
            false,
482
        );
483
        assert!(!matches!(
484
            without.action,
485
            DefaultAction::SplitBlockAtCursor { .. }
486
        ));
487
    }
488

            
489
    #[test]
490
    fn backspace_and_delete_merge_only_at_block_boundaries() {
491
        let layouts = BTreeMap::new();
492
        let focus = Some(dom_node(3));
493

            
494
        let at_start = EditingQueryState {
495
            is_contenteditable: true,
496
            cursor_at_block_start: true,
497
            cursor_at_block_end: false,
498
            host_preserves_newlines: false,
499
        };
500
        let mid = EditingQueryState {
501
            is_contenteditable: true,
502
            cursor_at_block_start: false,
503
            cursor_at_block_end: false,
504
            host_preserves_newlines: false,
505
        };
506
        let at_end = EditingQueryState {
507
            is_contenteditable: true,
508
            cursor_at_block_start: false,
509
            cursor_at_block_end: true,
510
            host_preserves_newlines: false,
511
        };
512

            
513
        let r = |key, e: &EditingQueryState| {
514
            determine_keyboard_default_action_with_editing(
515
                &kbd(key, &[]),
516
                focus,
517
                &layouts,
518
                false,
519
                Some(e),
520
            )
521
            .action
522
        };
523

            
524
        assert!(matches!(
525
            r(VirtualKeyCode::Back, &at_start),
526
            DefaultAction::MergeWithPrevious { .. }
527
        ));
528
        assert!(matches!(r(VirtualKeyCode::Back, &mid), DefaultAction::None));
529
        assert!(matches!(
530
            r(VirtualKeyCode::Delete, &at_end),
531
            DefaultAction::MergeWithNext { .. }
532
        ));
533
        assert!(matches!(r(VirtualKeyCode::Delete, &mid), DefaultAction::None));
534
    }
535

            
536
    use std::collections::HashMap;
537

            
538
    use azul_core::{
539
        a11y::{AccessibilityRole, AccessibilityState, SmallAriaInfo},
540
        dom::{Dom, NodeData, NodeType},
541
        events::{EventFilter, FocusEventFilter, HoverEventFilter},
542
        geom::LogicalRect,
543
        refany::RefAny,
544
        styled_dom::{NodeHierarchyItemId, StyledDom},
545
    };
546

            
547
    use super::*;
548
    use crate::solver3::{display_list::DisplayList, layout_tree::LayoutTree};
549

            
550
    // ------------------------------------------------------------------
551
    // Fixtures
552
    // ------------------------------------------------------------------
553

            
554
    /// A `NodeData` with a single callback attached. The callback pointer is a
555
    /// dummy `usize` — nothing in this module ever invokes it, both functions
556
    /// under test only look at `CoreCallbackData::event`.
557
    fn node_with_callback(node_type: NodeType, event: EventFilter) -> NodeData {
558
        let mut nd = NodeData::create_node(node_type);
559
        nd.add_callback(event, RefAny::new(0u32), 0usize);
560
        nd
561
    }
562

            
563
    fn node_with_role(node_type: NodeType, role: AccessibilityRole) -> NodeData {
564
        let mut nd = NodeData::create_node(node_type);
565
        nd.set_accessibility_info(SmallAriaInfo::label("label").with_role(role).to_full_info());
566
        nd
567
    }
568

            
569
    /// A control that *has* activation behaviour (role `PushButton`) but is
570
    /// explicitly disabled — `is_activatable` must reject it.
571
    fn disabled_control() -> NodeData {
572
        let mut nd = NodeData::create_node(NodeType::Input);
573
        let mut info = SmallAriaInfo::label("Save")
574
            .with_role(AccessibilityRole::PushButton)
575
            .to_full_info();
576
        info.states = vec![AccessibilityState::Unavailable].into();
577
        nd.set_accessibility_info(info);
578
        nd
579
    }
580

            
581
    /// One DOM whose children each have a *distinct* `NodeType`, so tests can
582
    /// look a node up by type without hardcoding flatten indices:
583
    ///
584
    /// - `Button`   — activatable (inherent), not a text input
585
    /// - `Div`      — neither
586
    /// - `TextArea` — text input (has a `Focus(TextInput)` callback), not activatable
587
    /// - `A`        — activatable (inherent) *and* a text input (pathological overlap)
588
    /// - `P`        — activatable via a `Hover(LeftMouseUp)` click callback
589
    /// - `Input`    — role `PushButton` but `Unavailable` → disabled
590
    /// - `Select`   — activatable purely via the `CheckButton` a11y role
591
    fn fixture() -> BTreeMap<DomId, DomLayoutResult> {
592
        let dom = Dom::create_body()
593
            .with_child(Dom::create_from_data(NodeData::create_button_no_a11y()))
594
            .with_child(Dom::create_from_data(NodeData::create_div()))
595
            .with_child(Dom::create_from_data(node_with_callback(
596
                NodeType::TextArea,
597
                EventFilter::Focus(FocusEventFilter::TextInput),
598
            )))
599
            .with_child(Dom::create_from_data(node_with_callback(
600
                NodeType::A,
601
                EventFilter::Focus(FocusEventFilter::TextInput),
602
            )))
603
            .with_child(Dom::create_from_data(node_with_callback(
604
                NodeType::P,
605
                EventFilter::Hover(HoverEventFilter::LeftMouseUp),
606
            )))
607
            .with_child(Dom::create_from_data(disabled_control()))
608
            .with_child(Dom::create_from_data(node_with_role(
609
                NodeType::Select,
610
                AccessibilityRole::CheckButton,
611
            )));
612

            
613
        let styled_dom = StyledDom::create_from_dom(dom);
614

            
615
        let mut map = BTreeMap::new();
616
        map.insert(
617
            DomId::ROOT_ID,
618
            DomLayoutResult {
619
                styled_dom,
620
                layout_tree: LayoutTree {
621
                    nodes: Vec::new(),
622
                    warm: Vec::new(),
623
                    cold: Vec::new(),
624
                    root: 0,
625
                    dom_to_layout: BTreeMap::new(),
626
                    children_arena: Vec::new(),
627
                    children_offsets: Vec::new(),
628
                    subtree_needs_intrinsic: Vec::new(),
629
                },
630
                calculated_positions: Vec::new(),
631
                viewport: LogicalRect::zero(),
632
                display_list: std::sync::Arc::new(DisplayList::default()),
633
                scroll_ids: HashMap::new(),
634
                scroll_id_to_node_id: HashMap::new(),
635
            },
636
        );
637
        map
638
    }
639

            
640
    fn dom_node(index: usize) -> DomNodeId {
641
        DomNodeId {
642
            dom: DomId::ROOT_ID,
643
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(index))),
644
        }
645
    }
646

            
647
    /// Locate the fixture node with the given `NodeType`. Scanning (instead of
648
    /// assuming `child i == NodeId(i + 1)`) keeps the tests honest even if the
649
    /// flatten order or anonymous-box insertion ever changes.
650
    fn node_of(layouts: &BTreeMap<DomId, DomLayoutResult>, matcher: fn(&NodeType) -> bool) -> DomNodeId {
651
        let layout = layouts.get(&DomId::ROOT_ID).expect("fixture dom missing");
652
        let container = layout.styled_dom.node_data.as_container();
653
        for i in 0..container.len() {
654
            if container
655
                .get(NodeId::new(i))
656
                .is_some_and(|nd| matcher(&nd.node_type))
657
            {
658
                return dom_node(i);
659
            }
660
        }
661
        panic!("fixture node not found");
662
    }
663

            
664
    fn button(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
665
        node_of(l, |t| matches!(t, NodeType::Button))
666
    }
667
    fn div(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
668
        node_of(l, |t| matches!(t, NodeType::Div))
669
    }
670
    fn textarea(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
671
        node_of(l, |t| matches!(t, NodeType::TextArea))
672
    }
673
    fn anchor(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
674
        node_of(l, |t| matches!(t, NodeType::A))
675
    }
676
    fn clickable_p(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
677
        node_of(l, |t| matches!(t, NodeType::P))
678
    }
679
    fn disabled(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
680
        node_of(l, |t| matches!(t, NodeType::Input))
681
    }
682
    fn role_only(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
683
        node_of(l, |t| matches!(t, NodeType::Select))
684
    }
685
    fn body(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
686
        node_of(l, |t| matches!(t, NodeType::Body))
687
    }
688

            
689
    // --- Deliberately broken node ids ---------------------------------
690

            
691
    /// References a `DomId` that is not in the map at all.
692
    fn missing_dom() -> DomNodeId {
693
        DomNodeId {
694
            dom: DomId { inner: 9999 },
695
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
696
        }
697
    }
698

            
699
    /// In-range DOM, node index far past the end of the container.
700
    fn out_of_bounds_node() -> DomNodeId {
701
        dom_node(9999)
702
    }
703

            
704
    /// The "no node" sentinel (`inner == 0` decodes to `None`).
705
    fn null_node() -> DomNodeId {
706
        DomNodeId {
707
            dom: DomId::ROOT_ID,
708
            node: NodeHierarchyItemId::NONE,
709
        }
710
    }
711

            
712
    /// Maximal raw encoding. Decodes to `NodeId(usize::MAX - 1)`; the container
713
    /// lookup must reject it rather than index out of bounds.
714
    fn max_node() -> DomNodeId {
715
        DomNodeId {
716
            dom: DomId::ROOT_ID,
717
            node: NodeHierarchyItemId::from_raw(usize::MAX),
718
        }
719
    }
720

            
721
    fn kbd(key: VirtualKeyCode, mods: &[VirtualKeyCode]) -> KeyboardState {
722
        let mut ks = KeyboardState::default();
723
        ks.current_virtual_keycode = Some(key).into();
724
        let mut pressed = mods.to_vec();
725
        pressed.push(key);
726
        ks.pressed_virtual_keycodes = pressed.into();
727
        ks
728
    }
729

            
730
    const ALL_KEYS: &[VirtualKeyCode] = &[
731
        VirtualKeyCode::Tab,
732
        VirtualKeyCode::Return,
733
        VirtualKeyCode::NumpadEnter,
734
        VirtualKeyCode::Space,
735
        VirtualKeyCode::Escape,
736
        VirtualKeyCode::Up,
737
        VirtualKeyCode::Down,
738
        VirtualKeyCode::Left,
739
        VirtualKeyCode::Right,
740
        VirtualKeyCode::PageUp,
741
        VirtualKeyCode::PageDown,
742
        VirtualKeyCode::Home,
743
        VirtualKeyCode::End,
744
        VirtualKeyCode::F1,
745
        VirtualKeyCode::Key1,
746
        VirtualKeyCode::LShift,
747
        VirtualKeyCode::LControl,
748
        VirtualKeyCode::LAlt,
749
    ];
750

            
751
    const MOD_SETS: &[&[VirtualKeyCode]] = &[
752
        &[],
753
        &[VirtualKeyCode::LShift],
754
        &[VirtualKeyCode::RShift],
755
        &[VirtualKeyCode::LControl],
756
        &[VirtualKeyCode::RControl],
757
        &[VirtualKeyCode::LAlt],
758
        &[VirtualKeyCode::RAlt],
759
        &[VirtualKeyCode::LControl, VirtualKeyCode::LShift],
760
        &[VirtualKeyCode::LAlt, VirtualKeyCode::LShift],
761
        &[
762
            VirtualKeyCode::LControl,
763
            VirtualKeyCode::LAlt,
764
            VirtualKeyCode::LShift,
765
        ],
766
    ];
767

            
768
    fn scroll(direction: ScrollDirection, amount: ScrollAmount) -> DefaultAction {
769
        DefaultAction::ScrollFocusedContainer { direction, amount }
770
    }
771

            
772
    // ==================================================================
773
    // determine_keyboard_default_action — prevention & missing key
774
    // ==================================================================
775

            
776
    #[test]
777
    fn prevented_beats_every_key_modifier_and_focus_combination() {
778
        let layouts = fixture();
779
        let focus_states = [
780
            None,
781
            Some(button(&layouts)),
782
            Some(textarea(&layouts)),
783
            Some(missing_dom()),
784
            Some(null_node()),
785
        ];
786

            
787
        for key in ALL_KEYS {
788
            for mods in MOD_SETS {
789
                for focus in focus_states {
790
                    let result =
791
                        determine_keyboard_default_action(&kbd(*key, mods), focus, &layouts, true);
792
                    assert!(result.prevented, "prevent_default() must be reported for {key:?}");
793
                    assert_eq!(
794
                        result.action,
795
                        DefaultAction::None,
796
                        "a prevented event must never carry an action ({key:?})"
797
                    );
798
                }
799
            }
800
        }
801
    }
802

            
803
    #[test]
804
    fn no_current_key_yields_the_default_result() {
805
        let layouts = fixture();
806
        // Modifiers held, keys "pressed", but no `current_virtual_keycode`.
807
        let mut ks = KeyboardState::default();
808
        ks.pressed_virtual_keycodes =
809
            vec![VirtualKeyCode::LShift, VirtualKeyCode::LControl].into();
810

            
811
        let result =
812
            determine_keyboard_default_action(&ks, Some(button(&layouts)), &layouts, false);
813
        assert_eq!(result.action, DefaultAction::None);
814
        assert!(!result.prevented);
815
    }
816

            
817
    #[test]
818
    fn never_reports_prevented_when_not_prevented() {
819
        let layouts = fixture();
820
        for key in ALL_KEYS {
821
            for mods in MOD_SETS {
822
                let result = determine_keyboard_default_action(
823
                    &kbd(*key, mods),
824
                    Some(button(&layouts)),
825
                    &layouts,
826
                    false,
827
                );
828
                assert!(!result.prevented, "{key:?} must not set `prevented`");
829
            }
830
        }
831
    }
832

            
833
    // ==================================================================
834
    // Tab
835
    // ==================================================================
836

            
837
    #[test]
838
    fn tab_with_ctrl_or_alt_yields_no_action() {
839
        let layouts = fixture();
840
        for mods in [
841
            &[VirtualKeyCode::LControl][..],
842
            &[VirtualKeyCode::RControl][..],
843
            &[VirtualKeyCode::LAlt][..],
844
            &[VirtualKeyCode::RAlt][..],
845
            // Ctrl/Alt must win even when Shift is also down.
846
            &[VirtualKeyCode::LControl, VirtualKeyCode::LShift][..],
847
            &[VirtualKeyCode::LAlt, VirtualKeyCode::RShift][..],
848
        ] {
849
            let result = determine_keyboard_default_action(
850
                &kbd(VirtualKeyCode::Tab, mods),
851
                None,
852
                &layouts,
853
                false,
854
            );
855
            assert_eq!(
856
                result.action,
857
                DefaultAction::None,
858
                "Ctrl/Alt+Tab belongs to the OS, not the app ({mods:?})"
859
            );
860
        }
861
    }
862

            
863
    #[test]
864
    fn tab_uses_either_shift_key_and_ignores_focus() {
865
        let layouts = fixture();
866
        for shift in [VirtualKeyCode::LShift, VirtualKeyCode::RShift] {
867
            let result = determine_keyboard_default_action(
868
                &kbd(VirtualKeyCode::Tab, &[shift]),
869
                Some(textarea(&layouts)),
870
                &layouts,
871
                false,
872
            );
873
            assert_eq!(result.action, DefaultAction::FocusPrevious);
874
        }
875
        let result = determine_keyboard_default_action(
876
            &kbd(VirtualKeyCode::Tab, &[]),
877
            Some(textarea(&layouts)),
878
            &layouts,
879
            false,
880
        );
881
        assert_eq!(result.action, DefaultAction::FocusNext);
882
    }
883

            
884
    // ==================================================================
885
    // Enter / NumpadEnter
886
    // ==================================================================
887

            
888
    #[test]
889
    fn enter_activates_every_kind_of_activatable_element() {
890
        let layouts = fixture();
891
        for target in [
892
            button(&layouts),
893
            anchor(&layouts),
894
            clickable_p(&layouts),
895
            role_only(&layouts),
896
        ] {
897
            for key in [VirtualKeyCode::Return, VirtualKeyCode::NumpadEnter] {
898
                let result = determine_keyboard_default_action(
899
                    &kbd(key, &[]),
900
                    Some(target),
901
                    &layouts,
902
                    false,
903
                );
904
                assert_eq!(
905
                    result.action,
906
                    DefaultAction::ActivateFocusedElement { target },
907
                    "{key:?} on an activatable element must activate exactly that element"
908
                );
909
            }
910
        }
911
    }
912

            
913
    #[test]
914
    fn enter_on_a_disabled_control_does_not_activate() {
915
        let layouts = fixture();
916
        let result = determine_keyboard_default_action(
917
            &kbd(VirtualKeyCode::Return, &[]),
918
            Some(disabled(&layouts)),
919
            &layouts,
920
            false,
921
        );
922
        assert_eq!(
923
            result.action,
924
            DefaultAction::None,
925
            "an Unavailable (disabled) control must never be activated"
926
        );
927
    }
928

            
929
    #[test]
930
    fn enter_on_non_activatable_or_unfocused_yields_no_action() {
931
        let layouts = fixture();
932
        for focus in [None, Some(div(&layouts)), Some(body(&layouts))] {
933
            let result = determine_keyboard_default_action(
934
                &kbd(VirtualKeyCode::Return, &[]),
935
                focus,
936
                &layouts,
937
                false,
938
            );
939
            assert_eq!(result.action, DefaultAction::None);
940
        }
941
    }
942

            
943
    #[test]
944
    fn enter_on_a_dangling_focus_target_does_not_panic() {
945
        let layouts = fixture();
946
        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
947

            
948
        // Bogus node ids against a populated map.
949
        for focus in [
950
            missing_dom(),
951
            out_of_bounds_node(),
952
            null_node(),
953
            max_node(),
954
        ] {
955
            let result = determine_keyboard_default_action(
956
                &kbd(VirtualKeyCode::Return, &[]),
957
                Some(focus),
958
                &layouts,
959
                false,
960
            );
961
            assert_eq!(
962
                result.action,
963
                DefaultAction::None,
964
                "a focus target that cannot be resolved must not be activated"
965
            );
966
        }
967

            
968
        // A perfectly valid node id against an empty map.
969
        let result = determine_keyboard_default_action(
970
            &kbd(VirtualKeyCode::Return, &[]),
971
            Some(dom_node(1)),
972
            &empty,
973
            false,
974
        );
975
        assert_eq!(result.action, DefaultAction::None);
976
    }
977

            
978
    // ==================================================================
979
    // Space
980
    // ==================================================================
981

            
982
    #[test]
983
    fn space_activates_a_focused_button() {
984
        let layouts = fixture();
985
        let target = button(&layouts);
986
        let result = determine_keyboard_default_action(
987
            &kbd(VirtualKeyCode::Space, &[]),
988
            Some(target),
989
            &layouts,
990
            false,
991
        );
992
        assert_eq!(
993
            result.action,
994
            DefaultAction::ActivateFocusedElement { target }
995
        );
996
    }
997

            
998
    #[test]
999
    fn space_in_a_text_input_is_swallowed_even_when_the_element_is_activatable() {
        let layouts = fixture();
        // Plain text input: not activatable at all.
        let result = determine_keyboard_default_action(
            &kbd(VirtualKeyCode::Space, &[]),
            Some(textarea(&layouts)),
            &layouts,
            false,
        );
        assert_eq!(
            result.action,
            DefaultAction::None,
            "Space in a text input must insert text, not scroll or activate"
        );
        // Pathological overlap: an <a> (inherently activatable) that also has a
        // TextInput callback. Text-input behaviour must win over activation,
        // otherwise typing a space in it would fire a synthetic click.
        let result = determine_keyboard_default_action(
            &kbd(VirtualKeyCode::Space, &[]),
            Some(anchor(&layouts)),
            &layouts,
            false,
        );
        assert_eq!(
            result.action,
            DefaultAction::None,
            "text-input behaviour must take precedence over activation for Space"
        );
    }
    #[test]
    fn space_pages_the_scroll_container_when_nothing_activatable_has_focus() {
        let layouts = fixture();
        for focus in [
            None,
            Some(div(&layouts)),
            Some(body(&layouts)),
            Some(disabled(&layouts)),
            Some(missing_dom()),
            Some(null_node()),
            Some(out_of_bounds_node()),
        ] {
            let down = determine_keyboard_default_action(
                &kbd(VirtualKeyCode::Space, &[]),
                focus,
                &layouts,
                false,
            );
            assert_eq!(down.action, scroll(ScrollDirection::Down, ScrollAmount::Page));
            let up = determine_keyboard_default_action(
                &kbd(VirtualKeyCode::Space, &[VirtualKeyCode::RShift]),
                focus,
                &layouts,
                false,
            );
            assert_eq!(
                up.action,
                scroll(ScrollDirection::Up, ScrollAmount::Page),
                "Shift+Space pages up"
            );
        }
    }
    // ==================================================================
    // Escape
    // ==================================================================
    #[test]
    fn escape_clears_focus_only_when_something_is_focused() {
        let layouts = fixture();
        // Even an unresolvable focus target counts as "focused" — Escape only
        // checks `is_some()`, and clearing a dangling focus is still correct.
        for focus in [
            button(&layouts),
            div(&layouts),
            null_node(),
            missing_dom(),
            max_node(),
        ] {
            let result = determine_keyboard_default_action(
                &kbd(VirtualKeyCode::Escape, &[]),
                Some(focus),
                &layouts,
                false,
            );
            assert_eq!(result.action, DefaultAction::ClearFocus);
        }
        let result = determine_keyboard_default_action(
            &kbd(VirtualKeyCode::Escape, &[]),
            None,
            &layouts,
            false,
        );
        assert_eq!(result.action, DefaultAction::None);
    }
    // ==================================================================
    // Arrows / PageUp / PageDown / Home / End
    // ==================================================================
    #[test]
    fn arrow_keys_map_to_their_own_direction_and_scroll_by_line() {
        let layouts = fixture();
        for (key, direction) in [
            (VirtualKeyCode::Up, ScrollDirection::Up),
            (VirtualKeyCode::Down, ScrollDirection::Down),
            (VirtualKeyCode::Left, ScrollDirection::Left),
            (VirtualKeyCode::Right, ScrollDirection::Right),
        ] {
            // No focus, non-text focus and unresolvable focus all scroll.
            for focus in [
                None,
                Some(button(&layouts)),
                Some(div(&layouts)),
                Some(missing_dom()),
                Some(null_node()),
            ] {
                let result = determine_keyboard_default_action(
                    &kbd(key, &[]),
                    focus,
                    &layouts,
                    false,
                );
                assert_eq!(
                    result.action,
                    scroll(direction, ScrollAmount::Line),
                    "{key:?} must scroll one line towards {direction:?}"
                );
            }
            // A focused text input claims the arrows for caret movement.
            let result = determine_keyboard_default_action(
                &kbd(key, &[]),
                Some(textarea(&layouts)),
                &layouts,
                false,
            );
            assert_eq!(
                result.action,
                DefaultAction::None,
                "{key:?} in a text input must move the caret, not scroll"
            );
        }
    }
    #[test]
    fn page_keys_scroll_a_page_regardless_of_modifiers_and_focus() {
        let layouts = fixture();
        for (key, direction) in [
            (VirtualKeyCode::PageUp, ScrollDirection::Up),
            (VirtualKeyCode::PageDown, ScrollDirection::Down),
        ] {
            for mods in MOD_SETS {
                for focus in [None, Some(textarea(&layouts)), Some(button(&layouts))] {
                    let result = determine_keyboard_default_action(
                        &kbd(key, mods),
                        focus,
                        &layouts,
                        false,
                    );
                    assert_eq!(result.action, scroll(direction, ScrollAmount::Page));
                }
            }
        }
    }
    #[test]
    fn home_and_end_switch_between_scrolling_and_focus_on_ctrl() {
        let layouts = fixture();
        for ctrl in [VirtualKeyCode::LControl, VirtualKeyCode::RControl] {
            let home =
                determine_keyboard_default_action(&kbd(VirtualKeyCode::Home, &[ctrl]), None, &layouts, false);
            assert_eq!(home.action, DefaultAction::FocusFirst);
            let end =
                determine_keyboard_default_action(&kbd(VirtualKeyCode::End, &[ctrl]), None, &layouts, false);
            assert_eq!(end.action, DefaultAction::FocusLast);
            // Ctrl still wins when Shift/Alt are also held.
            let home_shift = determine_keyboard_default_action(
                &kbd(VirtualKeyCode::Home, &[ctrl, VirtualKeyCode::LShift, VirtualKeyCode::LAlt]),
                Some(textarea(&layouts)),
                &layouts,
                false,
            );
            assert_eq!(home_shift.action, DefaultAction::FocusFirst);
        }
        // Without Ctrl: scroll to the document start / end. Note this happens
        // even inside a focused text input (no Home/End caret handling here).
        for focus in [None, Some(textarea(&layouts))] {
            let home = determine_keyboard_default_action(
                &kbd(VirtualKeyCode::Home, &[VirtualKeyCode::LShift]),
                focus,
                &layouts,
                false,
            );
            assert_eq!(home.action, scroll(ScrollDirection::Up, ScrollAmount::Document));
            let end = determine_keyboard_default_action(
                &kbd(VirtualKeyCode::End, &[]),
                focus,
                &layouts,
                false,
            );
            assert_eq!(end.action, scroll(ScrollDirection::Down, ScrollAmount::Document));
        }
    }
    #[test]
    fn keys_without_a_default_action_yield_none() {
        let layouts = fixture();
        for key in [
            VirtualKeyCode::F1,
            VirtualKeyCode::Key1,
            VirtualKeyCode::LShift,
            VirtualKeyCode::LControl,
            VirtualKeyCode::LAlt,
        ] {
            for focus in [None, Some(button(&layouts)), Some(textarea(&layouts))] {
                let result =
                    determine_keyboard_default_action(&kbd(key, &[]), focus, &layouts, false);
                assert_eq!(result.action, DefaultAction::None, "{key:?} has no default action");
            }
        }
    }
    // ==================================================================
    // Whole-surface smoke: no panic, deterministic, self-consistent
    // ==================================================================
    #[test]
    fn every_key_modifier_and_focus_combination_is_panic_free_and_deterministic() {
        let layouts = fixture();
        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
        let focus_states = [
            None,
            Some(button(&layouts)),
            Some(div(&layouts)),
            Some(textarea(&layouts)),
            Some(anchor(&layouts)),
            Some(disabled(&layouts)),
            Some(body(&layouts)),
            Some(missing_dom()),
            Some(out_of_bounds_node()),
            Some(null_node()),
            Some(max_node()),
        ];
        for key in ALL_KEYS {
            for mods in MOD_SETS {
                for focus in focus_states {
                    for maps in [&layouts, &empty] {
                        let ks = kbd(*key, mods);
                        let a = determine_keyboard_default_action(&ks, focus, maps, false);
                        let b = determine_keyboard_default_action(&ks, focus, maps, false);
                        assert_eq!(
                            a.action, b.action,
                            "{key:?} must be a pure function of its inputs"
                        );
                        assert_eq!(a.prevented, b.prevented);
                        // An activation can only ever target the focused node.
                        if let DefaultAction::ActivateFocusedElement { target } = a.action {
                            assert_eq!(
                                Some(target),
                                focus,
                                "activation must target the focused node, nothing else"
                            );
                        }
                    }
                }
            }
        }
    }
    // ==================================================================
    // is_element_activatable
    // ==================================================================
    #[test]
    fn is_element_activatable_true_and_false_cases() {
        let layouts = fixture();
        for (node, expected, why) in [
            (button(&layouts), true, "a <button> is inherently activatable"),
            (anchor(&layouts), true, "an <a> is inherently activatable"),
            (clickable_p(&layouts), true, "a click callback grants activation behaviour"),
            (role_only(&layouts), true, "the CheckButton a11y role grants activation behaviour"),
            (div(&layouts), false, "a plain <div> has no activation behaviour"),
            (body(&layouts), false, "the root <body> is not activatable"),
            (textarea(&layouts), false, "a text input is not activatable"),
            (disabled(&layouts), false, "an Unavailable control is not activatable"),
        ] {
            assert_eq!(is_element_activatable(&node, &layouts), expected, "{why}");
        }
    }
    #[test]
    fn is_element_activatable_rejects_every_unresolvable_node_id() {
        let layouts = fixture();
        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
        for node in [
            missing_dom(),
            out_of_bounds_node(),
            null_node(),
            max_node(),
        ] {
            assert!(!is_element_activatable(&node, &layouts));
        }
        // A valid id, but no layout results at all.
        assert!(!is_element_activatable(&button(&layouts), &empty));
        assert!(!is_element_activatable(&dom_node(0), &empty));
    }
    // ==================================================================
    // is_text_input
    // ==================================================================
    #[test]
    fn is_text_input_true_and_false_cases() {
        let layouts = fixture();
        for (node, expected, why) in [
            (textarea(&layouts), true, "a Focus(TextInput) callback marks a text input"),
            (anchor(&layouts), true, "even an <a> counts if it has a TextInput callback"),
            (button(&layouts), false, "a <button> is not a text input"),
            (div(&layouts), false, "a plain <div> is not a text input"),
            (body(&layouts), false, "the root <body> is not a text input"),
            (
                clickable_p(&layouts),
                false,
                "a non-TextInput callback must not be mistaken for a text input",
            ),
            (disabled(&layouts), false, "a disabled control is not a text input"),
        ] {
            assert_eq!(is_text_input(&node, &layouts), expected, "{why}");
        }
    }
    #[test]
    fn is_text_input_rejects_every_unresolvable_node_id() {
        let layouts = fixture();
        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
        for node in [
            missing_dom(),
            out_of_bounds_node(),
            null_node(),
            max_node(),
        ] {
            assert!(!is_text_input(&node, &layouts));
        }
        assert!(!is_text_input(&textarea(&layouts), &empty));
        assert!(!is_text_input(&dom_node(0), &empty));
    }
    #[test]
    fn predicates_are_pure_and_never_both_wrong_for_the_body_root() {
        let layouts = fixture();
        let root = body(&layouts);
        assert_eq!(
            is_element_activatable(&root, &layouts),
            is_element_activatable(&root, &layouts)
        );
        assert_eq!(is_text_input(&root, &layouts), is_text_input(&root, &layouts));
        assert!(!is_element_activatable(&root, &layouts));
        assert!(!is_text_input(&root, &layouts));
    }
    // ==================================================================
    // default_action_to_focus_target
    // ==================================================================
    /// Every `DefaultAction` variant, so the mapping test cannot silently miss
    /// a newly added one.
    fn all_default_actions() -> Vec<DefaultAction> {
        let node = dom_node(1);
        let mut v = vec![
            DefaultAction::FocusNext,
            DefaultAction::FocusPrevious,
            DefaultAction::FocusFirst,
            DefaultAction::FocusLast,
            DefaultAction::ClearFocus,
            DefaultAction::ActivateFocusedElement { target: node },
            DefaultAction::SubmitForm { form_node: node },
            DefaultAction::CloseModal { modal_node: node },
            DefaultAction::SelectAllText,
            DefaultAction::None,
        ];
        for direction in [
            ScrollDirection::Up,
            ScrollDirection::Down,
            ScrollDirection::Left,
            ScrollDirection::Right,
        ] {
            for amount in [ScrollAmount::Line, ScrollAmount::Page, ScrollAmount::Document] {
                v.push(scroll(direction, amount));
            }
        }
        v
    }
    #[test]
    fn focus_actions_map_to_their_focus_target() {
        for (action, target) in [
            (DefaultAction::FocusNext, FocusTarget::Next),
            (DefaultAction::FocusPrevious, FocusTarget::Previous),
            (DefaultAction::FocusFirst, FocusTarget::First),
            (DefaultAction::FocusLast, FocusTarget::Last),
            (DefaultAction::ClearFocus, FocusTarget::NoFocus),
        ] {
            assert_eq!(default_action_to_focus_target(&action), Some(target));
        }
    }
    #[test]
    fn mapping_is_some_exactly_for_focus_actions() {
        for action in all_default_actions() {
            let is_focus_action = matches!(
                action,
                DefaultAction::FocusNext
                    | DefaultAction::FocusPrevious
                    | DefaultAction::FocusFirst
                    | DefaultAction::FocusLast
                    | DefaultAction::ClearFocus
            );
            let mapped = default_action_to_focus_target(&action);
            assert_eq!(
                mapped.is_some(),
                is_focus_action,
                "{action:?} must map to a FocusTarget iff it is a focus action"
            );
            // Non-focus actions (activation, scrolling, ...) must never be
            // turned into a focus move.
            if !is_focus_action {
                assert_eq!(mapped, None);
            }
        }
    }
    #[test]
    fn mapping_is_injective_over_the_focus_actions() {
        let mapped: Vec<FocusTarget> = all_default_actions()
            .iter()
            .filter_map(default_action_to_focus_target)
            .collect();
        assert_eq!(mapped.len(), 5, "exactly five actions move focus");
        let mut deduped = mapped.clone();
        deduped.sort();
        deduped.dedup();
        assert_eq!(
            deduped.len(),
            mapped.len(),
            "two different focus actions must not collapse onto the same FocusTarget"
        );
    }
    #[test]
    fn mapping_is_usable_in_a_const_context() {
        const NEXT: Option<FocusTarget> = default_action_to_focus_target(&DefaultAction::FocusNext);
        const NOTHING: Option<FocusTarget> =
            default_action_to_focus_target(&DefaultAction::SelectAllText);
        assert_eq!(NEXT, Some(FocusTarget::Next));
        assert_eq!(NOTHING, None);
    }
    // ==================================================================
    // Round trip: key press -> DefaultAction -> FocusTarget
    // ==================================================================
    #[test]
    fn key_presses_round_trip_through_to_the_focus_manager() {
        let layouts = fixture();
        let focus = Some(button(&layouts));
        for (key, mods, expected) in [
            (VirtualKeyCode::Tab, &[][..], Some(FocusTarget::Next)),
            (
                VirtualKeyCode::Tab,
                &[VirtualKeyCode::LShift][..],
                Some(FocusTarget::Previous),
            ),
            (
                VirtualKeyCode::Home,
                &[VirtualKeyCode::LControl][..],
                Some(FocusTarget::First),
            ),
            (
                VirtualKeyCode::End,
                &[VirtualKeyCode::LControl][..],
                Some(FocusTarget::Last),
            ),
            (VirtualKeyCode::Escape, &[][..], Some(FocusTarget::NoFocus)),
            // Not a focus action: activation must not reach the focus manager.
            (VirtualKeyCode::Return, &[][..], None),
            (VirtualKeyCode::PageDown, &[][..], None),
        ] {
            let action =
                determine_keyboard_default_action(&kbd(key, mods), focus, &layouts, false).action;
            assert_eq!(
                default_action_to_focus_target(&action),
                expected,
                "{key:?} + {mods:?} round-trips to the wrong FocusTarget"
            );
        }
    }
    #[test]
    fn a_prevented_key_press_never_reaches_the_focus_manager() {
        let layouts = fixture();
        for key in ALL_KEYS {
            for mods in MOD_SETS {
                let action = determine_keyboard_default_action(
                    &kbd(*key, mods),
                    Some(button(&layouts)),
                    &layouts,
                    true,
                )
                .action;
                assert_eq!(
                    default_action_to_focus_target(&action),
                    None,
                    "{key:?} was prevented, so focus must not move"
                );
            }
        }
    }
}