Skip to main content

azul_layout/
default_actions.rs

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
37use alloc::vec::Vec;
38use azul_core::{
39    callbacks::FocusTarget,
40    dom::{DomId, DomNodeId, NodeId},
41    events::{DefaultAction, DefaultActionResult, ScrollAmount, ScrollDirection},
42    window::{KeyboardState, VirtualKeyCode},
43};
44use crate::window::DomLayoutResult;
45use std::collections::BTreeMap;
46
47/// Determine the default action for a keyboard event based on the
48/// current key, focused element, and whether `prevent_default()` was called.
49#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
50#[must_use] pub fn determine_keyboard_default_action(
51    keyboard_state: &KeyboardState,
52    focused_node: Option<DomNodeId>,
53    layout_results: &BTreeMap<DomId, DomLayoutResult>,
54    prevented: bool,
55) -> DefaultActionResult {
56    // If prevented, return early with no action
57    if prevented {
58        return DefaultActionResult::prevented();
59    }
60
61    // Get the current key (if any)
62    let Some(current_key) = keyboard_state.current_virtual_keycode.into_option() else {
63        return DefaultActionResult::default();
64    };
65
66    // Check modifier state
67    let shift_down = keyboard_state.shift_down();
68    let ctrl_down = keyboard_state.ctrl_down();
69    let alt_down = keyboard_state.alt_down();
70
71    // Determine action based on key
72    let action = match current_key {
73        // Tab navigation
74        VirtualKeyCode::Tab => {
75            if ctrl_down || alt_down {
76                // Ctrl+Tab / Alt+Tab are typically handled by OS
77                DefaultAction::None
78            } else if shift_down {
79                DefaultAction::FocusPrevious
80            } else {
81                DefaultAction::FocusNext
82            }
83        }
84
85        // Activation (Enter key)
86        VirtualKeyCode::Return | VirtualKeyCode::NumpadEnter => {
87            focused_node.as_ref().map_or(DefaultAction::None, |focus| if is_element_activatable(focus, layout_results) {
88                    DefaultAction::ActivateFocusedElement {
89                        target: *focus,
90                    }
91                } else {
92                    // Enter on non-activatable element - might submit form
93                    // For now, no action (form handling could be added later)
94                    DefaultAction::None
95                })
96        }
97
98        // Activation (Space key) — or page-scroll when nothing activatable
99        // has focus (MWA-C-scroll: the browser default; Shift+Space pages up).
100        VirtualKeyCode::Space => {
101            match focused_node.as_ref() {
102                Some(focus)
103                    if is_element_activatable(focus, layout_results)
104                        && !is_text_input(focus, layout_results) =>
105                {
106                    DefaultAction::ActivateFocusedElement { target: *focus }
107                }
108                // Space in text input should insert space (handled by text input system)
109                Some(focus) if is_text_input(focus, layout_results) => DefaultAction::None,
110                _ => DefaultAction::ScrollFocusedContainer {
111                    direction: if shift_down {
112                        ScrollDirection::Up
113                    } else {
114                        ScrollDirection::Down
115                    },
116                    amount: ScrollAmount::Page,
117                },
118            }
119        }
120
121        // Escape - clear focus
122        VirtualKeyCode::Escape => {
123            if focused_node.is_some() {
124                DefaultAction::ClearFocus
125            } else {
126                // Could close modal/dialog here if any is open
127                DefaultAction::None
128            }
129        }
130
131        // Arrow keys - scroll or navigate
132        VirtualKeyCode::Up | VirtualKeyCode::Down | VirtualKeyCode::Left | VirtualKeyCode::Right => {
133            let direction = match current_key {
134                VirtualKeyCode::Up => ScrollDirection::Up,
135                VirtualKeyCode::Down => ScrollDirection::Down,
136                VirtualKeyCode::Left => ScrollDirection::Left,
137                _ => ScrollDirection::Right,
138            };
139            // MWA-C-scroll: arrows scroll with NO focused node too (the
140            // consumer anchors on the hovered container then) — only a
141            // focused text input claims the arrows for caret movement.
142            focused_node.as_ref().map_or(
143                DefaultAction::ScrollFocusedContainer {
144                    direction,
145                    amount: ScrollAmount::Line,
146                },
147                |focus| {
148                    if is_text_input(focus, layout_results) {
149                        DefaultAction::None
150                    } else {
151                        DefaultAction::ScrollFocusedContainer {
152                            direction,
153                            amount: ScrollAmount::Line,
154                        }
155                    }
156                },
157            )
158        }
159
160        // Page Up/Down
161        VirtualKeyCode::PageUp => {
162            DefaultAction::ScrollFocusedContainer {
163                direction: ScrollDirection::Up,
164                amount: ScrollAmount::Page,
165            }
166        }
167        VirtualKeyCode::PageDown => {
168            DefaultAction::ScrollFocusedContainer {
169                direction: ScrollDirection::Down,
170                amount: ScrollAmount::Page,
171            }
172        }
173
174        // Home/End
175        VirtualKeyCode::Home => {
176            if ctrl_down {
177                // Ctrl+Home - go to start of document
178                DefaultAction::FocusFirst
179            } else {
180                DefaultAction::ScrollFocusedContainer {
181                    direction: ScrollDirection::Up,
182                    amount: ScrollAmount::Document,
183                }
184            }
185        }
186        VirtualKeyCode::End => {
187            if ctrl_down {
188                // Ctrl+End - go to end of document
189                DefaultAction::FocusLast
190            } else {
191                DefaultAction::ScrollFocusedContainer {
192                    direction: ScrollDirection::Down,
193                    amount: ScrollAmount::Document,
194                }
195            }
196        }
197
198        // All other keys - no default action
199        _ => DefaultAction::None,
200    };
201
202    DefaultActionResult::new(action)
203}
204
205/// Check if an element is activatable (can receive synthetic click from Enter/Space).
206fn is_element_activatable(node_id: &DomNodeId, layout_results: &BTreeMap<DomId, DomLayoutResult>) -> bool {
207    let Some(layout) = layout_results.get(&node_id.dom) else {
208        return false;
209    };
210    let Some(internal_id) = node_id.node.into_crate_internal() else {
211        return false;
212    };
213    layout.styled_dom.node_data.as_container()
214        .get(internal_id)
215        .is_some_and(azul_core::dom::NodeData::is_activatable)
216}
217
218/// Check if an element is a text input (where Space should insert text, not activate).
219fn is_text_input(node_id: &DomNodeId, layout_results: &BTreeMap<DomId, DomLayoutResult>) -> bool {
220    use azul_core::events::{EventFilter, FocusEventFilter};
221    let Some(layout) = layout_results.get(&node_id.dom) else {
222        return false;
223    };
224    let Some(internal_id) = node_id.node.into_crate_internal() else {
225        return false;
226    };
227    let node_data = layout.styled_dom.node_data.as_container();
228    let Some(node) = node_data.get(internal_id) else {
229        return false;
230    };
231
232    // Check if this node has a TextInput callback (FocusEventFilter::TextInput)
233    // which indicates it's a text input field
234    node.get_callbacks()
235        .iter()
236        .any(|cb| matches!(cb.event, EventFilter::Focus(FocusEventFilter::TextInput)))
237}
238
239/// Convert a `DefaultAction` to a `FocusTarget` for the focus manager.
240///
241/// This bridges the gap between the abstract `DefaultAction` and the
242/// concrete `FocusTarget` that the `FocusManager` understands.
243#[must_use] pub const fn default_action_to_focus_target(action: &DefaultAction) -> Option<FocusTarget> {
244    match action {
245        DefaultAction::FocusNext => Some(FocusTarget::Next),
246        DefaultAction::FocusPrevious => Some(FocusTarget::Previous),
247        DefaultAction::FocusFirst => Some(FocusTarget::First),
248        DefaultAction::FocusLast => Some(FocusTarget::Last),
249        DefaultAction::ClearFocus => Some(FocusTarget::NoFocus),
250        _ => None,
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use azul_core::styled_dom::NodeHierarchyItemId;
258
259    #[test]
260    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
261    fn test_tab_focus_next() {
262        let mut keyboard_state = KeyboardState::default();
263        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
264        
265        let result = determine_keyboard_default_action(
266            &keyboard_state,
267            None,
268            &BTreeMap::new(),
269            false,
270        );
271        
272        assert!(matches!(result.action, DefaultAction::FocusNext));
273        assert!(!result.prevented);
274    }
275
276    #[test]
277    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
278    fn test_shift_tab_focus_previous() {
279        let mut keyboard_state = KeyboardState::default();
280        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
281        // Add LShift to pressed keys to simulate Shift being held
282        keyboard_state.pressed_virtual_keycodes = vec![VirtualKeyCode::LShift, VirtualKeyCode::Tab].into();
283        
284        let result = determine_keyboard_default_action(
285            &keyboard_state,
286            None,
287            &BTreeMap::new(),
288            false,
289        );
290        
291        assert!(matches!(result.action, DefaultAction::FocusPrevious));
292    }
293
294    #[test]
295    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
296    fn test_escape_clears_focus() {
297        let mut keyboard_state = KeyboardState::default();
298        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Escape).into();
299        
300        let focused = Some(DomNodeId {
301            dom: DomId { inner: 0 },
302            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(1))),
303        });
304        
305        let result = determine_keyboard_default_action(
306            &keyboard_state,
307            focused,
308            &BTreeMap::new(),
309            false,
310        );
311        
312        assert!(matches!(result.action, DefaultAction::ClearFocus));
313    }
314
315    #[test]
316    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
317    fn test_prevented_returns_no_action() {
318        let mut keyboard_state = KeyboardState::default();
319        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
320        
321        let result = determine_keyboard_default_action(
322            &keyboard_state,
323            None,
324            &BTreeMap::new(),
325            true, // prevented!
326        );
327
328        assert!(result.prevented);
329        assert!(matches!(result.action, DefaultAction::None));
330    }
331}
332
333#[cfg(test)]
334#[allow(clippy::field_reassign_with_default)] // KeyboardState is built incrementally in the helpers
335mod autotest_generated {
336    use std::collections::HashMap;
337
338    use azul_core::{
339        a11y::{AccessibilityRole, AccessibilityState, SmallAriaInfo},
340        dom::{Dom, NodeData, NodeType},
341        events::{EventFilter, FocusEventFilter, HoverEventFilter},
342        geom::LogicalRect,
343        refany::RefAny,
344        styled_dom::{NodeHierarchyItemId, StyledDom},
345    };
346
347    use super::*;
348    use crate::solver3::{display_list::DisplayList, layout_tree::LayoutTree};
349
350    // ------------------------------------------------------------------
351    // Fixtures
352    // ------------------------------------------------------------------
353
354    /// A `NodeData` with a single callback attached. The callback pointer is a
355    /// dummy `usize` — nothing in this module ever invokes it, both functions
356    /// under test only look at `CoreCallbackData::event`.
357    fn node_with_callback(node_type: NodeType, event: EventFilter) -> NodeData {
358        let mut nd = NodeData::create_node(node_type);
359        nd.add_callback(event, RefAny::new(0u32), 0usize);
360        nd
361    }
362
363    fn node_with_role(node_type: NodeType, role: AccessibilityRole) -> NodeData {
364        let mut nd = NodeData::create_node(node_type);
365        nd.set_accessibility_info(SmallAriaInfo::label("label").with_role(role).to_full_info());
366        nd
367    }
368
369    /// A control that *has* activation behaviour (role `PushButton`) but is
370    /// explicitly disabled — `is_activatable` must reject it.
371    fn disabled_control() -> NodeData {
372        let mut nd = NodeData::create_node(NodeType::Input);
373        let mut info = SmallAriaInfo::label("Save")
374            .with_role(AccessibilityRole::PushButton)
375            .to_full_info();
376        info.states = vec![AccessibilityState::Unavailable].into();
377        nd.set_accessibility_info(info);
378        nd
379    }
380
381    /// One DOM whose children each have a *distinct* `NodeType`, so tests can
382    /// look a node up by type without hardcoding flatten indices:
383    ///
384    /// - `Button`   — activatable (inherent), not a text input
385    /// - `Div`      — neither
386    /// - `TextArea` — text input (has a `Focus(TextInput)` callback), not activatable
387    /// - `A`        — activatable (inherent) *and* a text input (pathological overlap)
388    /// - `P`        — activatable via a `Hover(LeftMouseUp)` click callback
389    /// - `Input`    — role `PushButton` but `Unavailable` → disabled
390    /// - `Select`   — activatable purely via the `CheckButton` a11y role
391    fn fixture() -> BTreeMap<DomId, DomLayoutResult> {
392        let dom = Dom::create_body()
393            .with_child(Dom::create_from_data(NodeData::create_button_no_a11y()))
394            .with_child(Dom::create_from_data(NodeData::create_div()))
395            .with_child(Dom::create_from_data(node_with_callback(
396                NodeType::TextArea,
397                EventFilter::Focus(FocusEventFilter::TextInput),
398            )))
399            .with_child(Dom::create_from_data(node_with_callback(
400                NodeType::A,
401                EventFilter::Focus(FocusEventFilter::TextInput),
402            )))
403            .with_child(Dom::create_from_data(node_with_callback(
404                NodeType::P,
405                EventFilter::Hover(HoverEventFilter::LeftMouseUp),
406            )))
407            .with_child(Dom::create_from_data(disabled_control()))
408            .with_child(Dom::create_from_data(node_with_role(
409                NodeType::Select,
410                AccessibilityRole::CheckButton,
411            )));
412
413        let styled_dom = StyledDom::create_from_dom(dom);
414
415        let mut map = BTreeMap::new();
416        map.insert(
417            DomId::ROOT_ID,
418            DomLayoutResult {
419                styled_dom,
420                layout_tree: LayoutTree {
421                    nodes: Vec::new(),
422                    warm: Vec::new(),
423                    cold: Vec::new(),
424                    root: 0,
425                    dom_to_layout: BTreeMap::new(),
426                    children_arena: Vec::new(),
427                    children_offsets: Vec::new(),
428                    subtree_needs_intrinsic: Vec::new(),
429                },
430                calculated_positions: Vec::new(),
431                viewport: LogicalRect::zero(),
432                display_list: DisplayList::default(),
433                scroll_ids: HashMap::new(),
434                scroll_id_to_node_id: HashMap::new(),
435            },
436        );
437        map
438    }
439
440    fn dom_node(index: usize) -> DomNodeId {
441        DomNodeId {
442            dom: DomId::ROOT_ID,
443            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(index))),
444        }
445    }
446
447    /// Locate the fixture node with the given `NodeType`. Scanning (instead of
448    /// assuming `child i == NodeId(i + 1)`) keeps the tests honest even if the
449    /// flatten order or anonymous-box insertion ever changes.
450    fn node_of(layouts: &BTreeMap<DomId, DomLayoutResult>, matcher: fn(&NodeType) -> bool) -> DomNodeId {
451        let layout = layouts.get(&DomId::ROOT_ID).expect("fixture dom missing");
452        let container = layout.styled_dom.node_data.as_container();
453        for i in 0..container.len() {
454            if container
455                .get(NodeId::new(i))
456                .is_some_and(|nd| matcher(&nd.node_type))
457            {
458                return dom_node(i);
459            }
460        }
461        panic!("fixture node not found");
462    }
463
464    fn button(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
465        node_of(l, |t| matches!(t, NodeType::Button))
466    }
467    fn div(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
468        node_of(l, |t| matches!(t, NodeType::Div))
469    }
470    fn textarea(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
471        node_of(l, |t| matches!(t, NodeType::TextArea))
472    }
473    fn anchor(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
474        node_of(l, |t| matches!(t, NodeType::A))
475    }
476    fn clickable_p(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
477        node_of(l, |t| matches!(t, NodeType::P))
478    }
479    fn disabled(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
480        node_of(l, |t| matches!(t, NodeType::Input))
481    }
482    fn role_only(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
483        node_of(l, |t| matches!(t, NodeType::Select))
484    }
485    fn body(l: &BTreeMap<DomId, DomLayoutResult>) -> DomNodeId {
486        node_of(l, |t| matches!(t, NodeType::Body))
487    }
488
489    // --- Deliberately broken node ids ---------------------------------
490
491    /// References a `DomId` that is not in the map at all.
492    fn missing_dom() -> DomNodeId {
493        DomNodeId {
494            dom: DomId { inner: 9999 },
495            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
496        }
497    }
498
499    /// In-range DOM, node index far past the end of the container.
500    fn out_of_bounds_node() -> DomNodeId {
501        dom_node(9999)
502    }
503
504    /// The "no node" sentinel (`inner == 0` decodes to `None`).
505    fn null_node() -> DomNodeId {
506        DomNodeId {
507            dom: DomId::ROOT_ID,
508            node: NodeHierarchyItemId::NONE,
509        }
510    }
511
512    /// Maximal raw encoding. Decodes to `NodeId(usize::MAX - 1)`; the container
513    /// lookup must reject it rather than index out of bounds.
514    fn max_node() -> DomNodeId {
515        DomNodeId {
516            dom: DomId::ROOT_ID,
517            node: NodeHierarchyItemId::from_raw(usize::MAX),
518        }
519    }
520
521    fn kbd(key: VirtualKeyCode, mods: &[VirtualKeyCode]) -> KeyboardState {
522        let mut ks = KeyboardState::default();
523        ks.current_virtual_keycode = Some(key).into();
524        let mut pressed = mods.to_vec();
525        pressed.push(key);
526        ks.pressed_virtual_keycodes = pressed.into();
527        ks
528    }
529
530    const ALL_KEYS: &[VirtualKeyCode] = &[
531        VirtualKeyCode::Tab,
532        VirtualKeyCode::Return,
533        VirtualKeyCode::NumpadEnter,
534        VirtualKeyCode::Space,
535        VirtualKeyCode::Escape,
536        VirtualKeyCode::Up,
537        VirtualKeyCode::Down,
538        VirtualKeyCode::Left,
539        VirtualKeyCode::Right,
540        VirtualKeyCode::PageUp,
541        VirtualKeyCode::PageDown,
542        VirtualKeyCode::Home,
543        VirtualKeyCode::End,
544        VirtualKeyCode::F1,
545        VirtualKeyCode::Key1,
546        VirtualKeyCode::LShift,
547        VirtualKeyCode::LControl,
548        VirtualKeyCode::LAlt,
549    ];
550
551    const MOD_SETS: &[&[VirtualKeyCode]] = &[
552        &[],
553        &[VirtualKeyCode::LShift],
554        &[VirtualKeyCode::RShift],
555        &[VirtualKeyCode::LControl],
556        &[VirtualKeyCode::RControl],
557        &[VirtualKeyCode::LAlt],
558        &[VirtualKeyCode::RAlt],
559        &[VirtualKeyCode::LControl, VirtualKeyCode::LShift],
560        &[VirtualKeyCode::LAlt, VirtualKeyCode::LShift],
561        &[
562            VirtualKeyCode::LControl,
563            VirtualKeyCode::LAlt,
564            VirtualKeyCode::LShift,
565        ],
566    ];
567
568    fn scroll(direction: ScrollDirection, amount: ScrollAmount) -> DefaultAction {
569        DefaultAction::ScrollFocusedContainer { direction, amount }
570    }
571
572    // ==================================================================
573    // determine_keyboard_default_action — prevention & missing key
574    // ==================================================================
575
576    #[test]
577    fn prevented_beats_every_key_modifier_and_focus_combination() {
578        let layouts = fixture();
579        let focus_states = [
580            None,
581            Some(button(&layouts)),
582            Some(textarea(&layouts)),
583            Some(missing_dom()),
584            Some(null_node()),
585        ];
586
587        for key in ALL_KEYS {
588            for mods in MOD_SETS {
589                for focus in focus_states {
590                    let result =
591                        determine_keyboard_default_action(&kbd(*key, mods), focus, &layouts, true);
592                    assert!(result.prevented, "prevent_default() must be reported for {key:?}");
593                    assert_eq!(
594                        result.action,
595                        DefaultAction::None,
596                        "a prevented event must never carry an action ({key:?})"
597                    );
598                }
599            }
600        }
601    }
602
603    #[test]
604    fn no_current_key_yields_the_default_result() {
605        let layouts = fixture();
606        // Modifiers held, keys "pressed", but no `current_virtual_keycode`.
607        let mut ks = KeyboardState::default();
608        ks.pressed_virtual_keycodes =
609            vec![VirtualKeyCode::LShift, VirtualKeyCode::LControl].into();
610
611        let result =
612            determine_keyboard_default_action(&ks, Some(button(&layouts)), &layouts, false);
613        assert_eq!(result.action, DefaultAction::None);
614        assert!(!result.prevented);
615    }
616
617    #[test]
618    fn never_reports_prevented_when_not_prevented() {
619        let layouts = fixture();
620        for key in ALL_KEYS {
621            for mods in MOD_SETS {
622                let result = determine_keyboard_default_action(
623                    &kbd(*key, mods),
624                    Some(button(&layouts)),
625                    &layouts,
626                    false,
627                );
628                assert!(!result.prevented, "{key:?} must not set `prevented`");
629            }
630        }
631    }
632
633    // ==================================================================
634    // Tab
635    // ==================================================================
636
637    #[test]
638    fn tab_with_ctrl_or_alt_yields_no_action() {
639        let layouts = fixture();
640        for mods in [
641            &[VirtualKeyCode::LControl][..],
642            &[VirtualKeyCode::RControl][..],
643            &[VirtualKeyCode::LAlt][..],
644            &[VirtualKeyCode::RAlt][..],
645            // Ctrl/Alt must win even when Shift is also down.
646            &[VirtualKeyCode::LControl, VirtualKeyCode::LShift][..],
647            &[VirtualKeyCode::LAlt, VirtualKeyCode::RShift][..],
648        ] {
649            let result = determine_keyboard_default_action(
650                &kbd(VirtualKeyCode::Tab, mods),
651                None,
652                &layouts,
653                false,
654            );
655            assert_eq!(
656                result.action,
657                DefaultAction::None,
658                "Ctrl/Alt+Tab belongs to the OS, not the app ({mods:?})"
659            );
660        }
661    }
662
663    #[test]
664    fn tab_uses_either_shift_key_and_ignores_focus() {
665        let layouts = fixture();
666        for shift in [VirtualKeyCode::LShift, VirtualKeyCode::RShift] {
667            let result = determine_keyboard_default_action(
668                &kbd(VirtualKeyCode::Tab, &[shift]),
669                Some(textarea(&layouts)),
670                &layouts,
671                false,
672            );
673            assert_eq!(result.action, DefaultAction::FocusPrevious);
674        }
675        let result = determine_keyboard_default_action(
676            &kbd(VirtualKeyCode::Tab, &[]),
677            Some(textarea(&layouts)),
678            &layouts,
679            false,
680        );
681        assert_eq!(result.action, DefaultAction::FocusNext);
682    }
683
684    // ==================================================================
685    // Enter / NumpadEnter
686    // ==================================================================
687
688    #[test]
689    fn enter_activates_every_kind_of_activatable_element() {
690        let layouts = fixture();
691        for target in [
692            button(&layouts),
693            anchor(&layouts),
694            clickable_p(&layouts),
695            role_only(&layouts),
696        ] {
697            for key in [VirtualKeyCode::Return, VirtualKeyCode::NumpadEnter] {
698                let result = determine_keyboard_default_action(
699                    &kbd(key, &[]),
700                    Some(target),
701                    &layouts,
702                    false,
703                );
704                assert_eq!(
705                    result.action,
706                    DefaultAction::ActivateFocusedElement { target },
707                    "{key:?} on an activatable element must activate exactly that element"
708                );
709            }
710        }
711    }
712
713    #[test]
714    fn enter_on_a_disabled_control_does_not_activate() {
715        let layouts = fixture();
716        let result = determine_keyboard_default_action(
717            &kbd(VirtualKeyCode::Return, &[]),
718            Some(disabled(&layouts)),
719            &layouts,
720            false,
721        );
722        assert_eq!(
723            result.action,
724            DefaultAction::None,
725            "an Unavailable (disabled) control must never be activated"
726        );
727    }
728
729    #[test]
730    fn enter_on_non_activatable_or_unfocused_yields_no_action() {
731        let layouts = fixture();
732        for focus in [None, Some(div(&layouts)), Some(body(&layouts))] {
733            let result = determine_keyboard_default_action(
734                &kbd(VirtualKeyCode::Return, &[]),
735                focus,
736                &layouts,
737                false,
738            );
739            assert_eq!(result.action, DefaultAction::None);
740        }
741    }
742
743    #[test]
744    fn enter_on_a_dangling_focus_target_does_not_panic() {
745        let layouts = fixture();
746        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
747
748        // Bogus node ids against a populated map.
749        for focus in [
750            missing_dom(),
751            out_of_bounds_node(),
752            null_node(),
753            max_node(),
754        ] {
755            let result = determine_keyboard_default_action(
756                &kbd(VirtualKeyCode::Return, &[]),
757                Some(focus),
758                &layouts,
759                false,
760            );
761            assert_eq!(
762                result.action,
763                DefaultAction::None,
764                "a focus target that cannot be resolved must not be activated"
765            );
766        }
767
768        // A perfectly valid node id against an empty map.
769        let result = determine_keyboard_default_action(
770            &kbd(VirtualKeyCode::Return, &[]),
771            Some(dom_node(1)),
772            &empty,
773            false,
774        );
775        assert_eq!(result.action, DefaultAction::None);
776    }
777
778    // ==================================================================
779    // Space
780    // ==================================================================
781
782    #[test]
783    fn space_activates_a_focused_button() {
784        let layouts = fixture();
785        let target = button(&layouts);
786        let result = determine_keyboard_default_action(
787            &kbd(VirtualKeyCode::Space, &[]),
788            Some(target),
789            &layouts,
790            false,
791        );
792        assert_eq!(
793            result.action,
794            DefaultAction::ActivateFocusedElement { target }
795        );
796    }
797
798    #[test]
799    fn space_in_a_text_input_is_swallowed_even_when_the_element_is_activatable() {
800        let layouts = fixture();
801        // Plain text input: not activatable at all.
802        let result = determine_keyboard_default_action(
803            &kbd(VirtualKeyCode::Space, &[]),
804            Some(textarea(&layouts)),
805            &layouts,
806            false,
807        );
808        assert_eq!(
809            result.action,
810            DefaultAction::None,
811            "Space in a text input must insert text, not scroll or activate"
812        );
813
814        // Pathological overlap: an <a> (inherently activatable) that also has a
815        // TextInput callback. Text-input behaviour must win over activation,
816        // otherwise typing a space in it would fire a synthetic click.
817        let result = determine_keyboard_default_action(
818            &kbd(VirtualKeyCode::Space, &[]),
819            Some(anchor(&layouts)),
820            &layouts,
821            false,
822        );
823        assert_eq!(
824            result.action,
825            DefaultAction::None,
826            "text-input behaviour must take precedence over activation for Space"
827        );
828    }
829
830    #[test]
831    fn space_pages_the_scroll_container_when_nothing_activatable_has_focus() {
832        let layouts = fixture();
833        for focus in [
834            None,
835            Some(div(&layouts)),
836            Some(body(&layouts)),
837            Some(disabled(&layouts)),
838            Some(missing_dom()),
839            Some(null_node()),
840            Some(out_of_bounds_node()),
841        ] {
842            let down = determine_keyboard_default_action(
843                &kbd(VirtualKeyCode::Space, &[]),
844                focus,
845                &layouts,
846                false,
847            );
848            assert_eq!(down.action, scroll(ScrollDirection::Down, ScrollAmount::Page));
849
850            let up = determine_keyboard_default_action(
851                &kbd(VirtualKeyCode::Space, &[VirtualKeyCode::RShift]),
852                focus,
853                &layouts,
854                false,
855            );
856            assert_eq!(
857                up.action,
858                scroll(ScrollDirection::Up, ScrollAmount::Page),
859                "Shift+Space pages up"
860            );
861        }
862    }
863
864    // ==================================================================
865    // Escape
866    // ==================================================================
867
868    #[test]
869    fn escape_clears_focus_only_when_something_is_focused() {
870        let layouts = fixture();
871        // Even an unresolvable focus target counts as "focused" — Escape only
872        // checks `is_some()`, and clearing a dangling focus is still correct.
873        for focus in [
874            button(&layouts),
875            div(&layouts),
876            null_node(),
877            missing_dom(),
878            max_node(),
879        ] {
880            let result = determine_keyboard_default_action(
881                &kbd(VirtualKeyCode::Escape, &[]),
882                Some(focus),
883                &layouts,
884                false,
885            );
886            assert_eq!(result.action, DefaultAction::ClearFocus);
887        }
888
889        let result = determine_keyboard_default_action(
890            &kbd(VirtualKeyCode::Escape, &[]),
891            None,
892            &layouts,
893            false,
894        );
895        assert_eq!(result.action, DefaultAction::None);
896    }
897
898    // ==================================================================
899    // Arrows / PageUp / PageDown / Home / End
900    // ==================================================================
901
902    #[test]
903    fn arrow_keys_map_to_their_own_direction_and_scroll_by_line() {
904        let layouts = fixture();
905        for (key, direction) in [
906            (VirtualKeyCode::Up, ScrollDirection::Up),
907            (VirtualKeyCode::Down, ScrollDirection::Down),
908            (VirtualKeyCode::Left, ScrollDirection::Left),
909            (VirtualKeyCode::Right, ScrollDirection::Right),
910        ] {
911            // No focus, non-text focus and unresolvable focus all scroll.
912            for focus in [
913                None,
914                Some(button(&layouts)),
915                Some(div(&layouts)),
916                Some(missing_dom()),
917                Some(null_node()),
918            ] {
919                let result = determine_keyboard_default_action(
920                    &kbd(key, &[]),
921                    focus,
922                    &layouts,
923                    false,
924                );
925                assert_eq!(
926                    result.action,
927                    scroll(direction, ScrollAmount::Line),
928                    "{key:?} must scroll one line towards {direction:?}"
929                );
930            }
931
932            // A focused text input claims the arrows for caret movement.
933            let result = determine_keyboard_default_action(
934                &kbd(key, &[]),
935                Some(textarea(&layouts)),
936                &layouts,
937                false,
938            );
939            assert_eq!(
940                result.action,
941                DefaultAction::None,
942                "{key:?} in a text input must move the caret, not scroll"
943            );
944        }
945    }
946
947    #[test]
948    fn page_keys_scroll_a_page_regardless_of_modifiers_and_focus() {
949        let layouts = fixture();
950        for (key, direction) in [
951            (VirtualKeyCode::PageUp, ScrollDirection::Up),
952            (VirtualKeyCode::PageDown, ScrollDirection::Down),
953        ] {
954            for mods in MOD_SETS {
955                for focus in [None, Some(textarea(&layouts)), Some(button(&layouts))] {
956                    let result = determine_keyboard_default_action(
957                        &kbd(key, mods),
958                        focus,
959                        &layouts,
960                        false,
961                    );
962                    assert_eq!(result.action, scroll(direction, ScrollAmount::Page));
963                }
964            }
965        }
966    }
967
968    #[test]
969    fn home_and_end_switch_between_scrolling_and_focus_on_ctrl() {
970        let layouts = fixture();
971
972        for ctrl in [VirtualKeyCode::LControl, VirtualKeyCode::RControl] {
973            let home =
974                determine_keyboard_default_action(&kbd(VirtualKeyCode::Home, &[ctrl]), None, &layouts, false);
975            assert_eq!(home.action, DefaultAction::FocusFirst);
976
977            let end =
978                determine_keyboard_default_action(&kbd(VirtualKeyCode::End, &[ctrl]), None, &layouts, false);
979            assert_eq!(end.action, DefaultAction::FocusLast);
980
981            // Ctrl still wins when Shift/Alt are also held.
982            let home_shift = determine_keyboard_default_action(
983                &kbd(VirtualKeyCode::Home, &[ctrl, VirtualKeyCode::LShift, VirtualKeyCode::LAlt]),
984                Some(textarea(&layouts)),
985                &layouts,
986                false,
987            );
988            assert_eq!(home_shift.action, DefaultAction::FocusFirst);
989        }
990
991        // Without Ctrl: scroll to the document start / end. Note this happens
992        // even inside a focused text input (no Home/End caret handling here).
993        for focus in [None, Some(textarea(&layouts))] {
994            let home = determine_keyboard_default_action(
995                &kbd(VirtualKeyCode::Home, &[VirtualKeyCode::LShift]),
996                focus,
997                &layouts,
998                false,
999            );
1000            assert_eq!(home.action, scroll(ScrollDirection::Up, ScrollAmount::Document));
1001
1002            let end = determine_keyboard_default_action(
1003                &kbd(VirtualKeyCode::End, &[]),
1004                focus,
1005                &layouts,
1006                false,
1007            );
1008            assert_eq!(end.action, scroll(ScrollDirection::Down, ScrollAmount::Document));
1009        }
1010    }
1011
1012    #[test]
1013    fn keys_without_a_default_action_yield_none() {
1014        let layouts = fixture();
1015        for key in [
1016            VirtualKeyCode::F1,
1017            VirtualKeyCode::Key1,
1018            VirtualKeyCode::LShift,
1019            VirtualKeyCode::LControl,
1020            VirtualKeyCode::LAlt,
1021        ] {
1022            for focus in [None, Some(button(&layouts)), Some(textarea(&layouts))] {
1023                let result =
1024                    determine_keyboard_default_action(&kbd(key, &[]), focus, &layouts, false);
1025                assert_eq!(result.action, DefaultAction::None, "{key:?} has no default action");
1026            }
1027        }
1028    }
1029
1030    // ==================================================================
1031    // Whole-surface smoke: no panic, deterministic, self-consistent
1032    // ==================================================================
1033
1034    #[test]
1035    fn every_key_modifier_and_focus_combination_is_panic_free_and_deterministic() {
1036        let layouts = fixture();
1037        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
1038        let focus_states = [
1039            None,
1040            Some(button(&layouts)),
1041            Some(div(&layouts)),
1042            Some(textarea(&layouts)),
1043            Some(anchor(&layouts)),
1044            Some(disabled(&layouts)),
1045            Some(body(&layouts)),
1046            Some(missing_dom()),
1047            Some(out_of_bounds_node()),
1048            Some(null_node()),
1049            Some(max_node()),
1050        ];
1051
1052        for key in ALL_KEYS {
1053            for mods in MOD_SETS {
1054                for focus in focus_states {
1055                    for maps in [&layouts, &empty] {
1056                        let ks = kbd(*key, mods);
1057                        let a = determine_keyboard_default_action(&ks, focus, maps, false);
1058                        let b = determine_keyboard_default_action(&ks, focus, maps, false);
1059                        assert_eq!(
1060                            a.action, b.action,
1061                            "{key:?} must be a pure function of its inputs"
1062                        );
1063                        assert_eq!(a.prevented, b.prevented);
1064
1065                        // An activation can only ever target the focused node.
1066                        if let DefaultAction::ActivateFocusedElement { target } = a.action {
1067                            assert_eq!(
1068                                Some(target),
1069                                focus,
1070                                "activation must target the focused node, nothing else"
1071                            );
1072                        }
1073                    }
1074                }
1075            }
1076        }
1077    }
1078
1079    // ==================================================================
1080    // is_element_activatable
1081    // ==================================================================
1082
1083    #[test]
1084    fn is_element_activatable_true_and_false_cases() {
1085        let layouts = fixture();
1086        for (node, expected, why) in [
1087            (button(&layouts), true, "a <button> is inherently activatable"),
1088            (anchor(&layouts), true, "an <a> is inherently activatable"),
1089            (clickable_p(&layouts), true, "a click callback grants activation behaviour"),
1090            (role_only(&layouts), true, "the CheckButton a11y role grants activation behaviour"),
1091            (div(&layouts), false, "a plain <div> has no activation behaviour"),
1092            (body(&layouts), false, "the root <body> is not activatable"),
1093            (textarea(&layouts), false, "a text input is not activatable"),
1094            (disabled(&layouts), false, "an Unavailable control is not activatable"),
1095        ] {
1096            assert_eq!(is_element_activatable(&node, &layouts), expected, "{why}");
1097        }
1098    }
1099
1100    #[test]
1101    fn is_element_activatable_rejects_every_unresolvable_node_id() {
1102        let layouts = fixture();
1103        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
1104
1105        for node in [
1106            missing_dom(),
1107            out_of_bounds_node(),
1108            null_node(),
1109            max_node(),
1110        ] {
1111            assert!(!is_element_activatable(&node, &layouts));
1112        }
1113
1114        // A valid id, but no layout results at all.
1115        assert!(!is_element_activatable(&button(&layouts), &empty));
1116        assert!(!is_element_activatable(&dom_node(0), &empty));
1117    }
1118
1119    // ==================================================================
1120    // is_text_input
1121    // ==================================================================
1122
1123    #[test]
1124    fn is_text_input_true_and_false_cases() {
1125        let layouts = fixture();
1126        for (node, expected, why) in [
1127            (textarea(&layouts), true, "a Focus(TextInput) callback marks a text input"),
1128            (anchor(&layouts), true, "even an <a> counts if it has a TextInput callback"),
1129            (button(&layouts), false, "a <button> is not a text input"),
1130            (div(&layouts), false, "a plain <div> is not a text input"),
1131            (body(&layouts), false, "the root <body> is not a text input"),
1132            (
1133                clickable_p(&layouts),
1134                false,
1135                "a non-TextInput callback must not be mistaken for a text input",
1136            ),
1137            (disabled(&layouts), false, "a disabled control is not a text input"),
1138        ] {
1139            assert_eq!(is_text_input(&node, &layouts), expected, "{why}");
1140        }
1141    }
1142
1143    #[test]
1144    fn is_text_input_rejects_every_unresolvable_node_id() {
1145        let layouts = fixture();
1146        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
1147
1148        for node in [
1149            missing_dom(),
1150            out_of_bounds_node(),
1151            null_node(),
1152            max_node(),
1153        ] {
1154            assert!(!is_text_input(&node, &layouts));
1155        }
1156
1157        assert!(!is_text_input(&textarea(&layouts), &empty));
1158        assert!(!is_text_input(&dom_node(0), &empty));
1159    }
1160
1161    #[test]
1162    fn predicates_are_pure_and_never_both_wrong_for_the_body_root() {
1163        let layouts = fixture();
1164        let root = body(&layouts);
1165        assert_eq!(
1166            is_element_activatable(&root, &layouts),
1167            is_element_activatable(&root, &layouts)
1168        );
1169        assert_eq!(is_text_input(&root, &layouts), is_text_input(&root, &layouts));
1170        assert!(!is_element_activatable(&root, &layouts));
1171        assert!(!is_text_input(&root, &layouts));
1172    }
1173
1174    // ==================================================================
1175    // default_action_to_focus_target
1176    // ==================================================================
1177
1178    /// Every `DefaultAction` variant, so the mapping test cannot silently miss
1179    /// a newly added one.
1180    fn all_default_actions() -> Vec<DefaultAction> {
1181        let node = dom_node(1);
1182        let mut v = vec![
1183            DefaultAction::FocusNext,
1184            DefaultAction::FocusPrevious,
1185            DefaultAction::FocusFirst,
1186            DefaultAction::FocusLast,
1187            DefaultAction::ClearFocus,
1188            DefaultAction::ActivateFocusedElement { target: node },
1189            DefaultAction::SubmitForm { form_node: node },
1190            DefaultAction::CloseModal { modal_node: node },
1191            DefaultAction::SelectAllText,
1192            DefaultAction::None,
1193        ];
1194        for direction in [
1195            ScrollDirection::Up,
1196            ScrollDirection::Down,
1197            ScrollDirection::Left,
1198            ScrollDirection::Right,
1199        ] {
1200            for amount in [ScrollAmount::Line, ScrollAmount::Page, ScrollAmount::Document] {
1201                v.push(scroll(direction, amount));
1202            }
1203        }
1204        v
1205    }
1206
1207    #[test]
1208    fn focus_actions_map_to_their_focus_target() {
1209        for (action, target) in [
1210            (DefaultAction::FocusNext, FocusTarget::Next),
1211            (DefaultAction::FocusPrevious, FocusTarget::Previous),
1212            (DefaultAction::FocusFirst, FocusTarget::First),
1213            (DefaultAction::FocusLast, FocusTarget::Last),
1214            (DefaultAction::ClearFocus, FocusTarget::NoFocus),
1215        ] {
1216            assert_eq!(default_action_to_focus_target(&action), Some(target));
1217        }
1218    }
1219
1220    #[test]
1221    fn mapping_is_some_exactly_for_focus_actions() {
1222        for action in all_default_actions() {
1223            let is_focus_action = matches!(
1224                action,
1225                DefaultAction::FocusNext
1226                    | DefaultAction::FocusPrevious
1227                    | DefaultAction::FocusFirst
1228                    | DefaultAction::FocusLast
1229                    | DefaultAction::ClearFocus
1230            );
1231            let mapped = default_action_to_focus_target(&action);
1232            assert_eq!(
1233                mapped.is_some(),
1234                is_focus_action,
1235                "{action:?} must map to a FocusTarget iff it is a focus action"
1236            );
1237            // Non-focus actions (activation, scrolling, ...) must never be
1238            // turned into a focus move.
1239            if !is_focus_action {
1240                assert_eq!(mapped, None);
1241            }
1242        }
1243    }
1244
1245    #[test]
1246    fn mapping_is_injective_over_the_focus_actions() {
1247        let mapped: Vec<FocusTarget> = all_default_actions()
1248            .iter()
1249            .filter_map(default_action_to_focus_target)
1250            .collect();
1251        assert_eq!(mapped.len(), 5, "exactly five actions move focus");
1252        let mut deduped = mapped.clone();
1253        deduped.sort();
1254        deduped.dedup();
1255        assert_eq!(
1256            deduped.len(),
1257            mapped.len(),
1258            "two different focus actions must not collapse onto the same FocusTarget"
1259        );
1260    }
1261
1262    #[test]
1263    fn mapping_is_usable_in_a_const_context() {
1264        const NEXT: Option<FocusTarget> = default_action_to_focus_target(&DefaultAction::FocusNext);
1265        const NOTHING: Option<FocusTarget> =
1266            default_action_to_focus_target(&DefaultAction::SelectAllText);
1267        assert_eq!(NEXT, Some(FocusTarget::Next));
1268        assert_eq!(NOTHING, None);
1269    }
1270
1271    // ==================================================================
1272    // Round trip: key press -> DefaultAction -> FocusTarget
1273    // ==================================================================
1274
1275    #[test]
1276    fn key_presses_round_trip_through_to_the_focus_manager() {
1277        let layouts = fixture();
1278        let focus = Some(button(&layouts));
1279
1280        for (key, mods, expected) in [
1281            (VirtualKeyCode::Tab, &[][..], Some(FocusTarget::Next)),
1282            (
1283                VirtualKeyCode::Tab,
1284                &[VirtualKeyCode::LShift][..],
1285                Some(FocusTarget::Previous),
1286            ),
1287            (
1288                VirtualKeyCode::Home,
1289                &[VirtualKeyCode::LControl][..],
1290                Some(FocusTarget::First),
1291            ),
1292            (
1293                VirtualKeyCode::End,
1294                &[VirtualKeyCode::LControl][..],
1295                Some(FocusTarget::Last),
1296            ),
1297            (VirtualKeyCode::Escape, &[][..], Some(FocusTarget::NoFocus)),
1298            // Not a focus action: activation must not reach the focus manager.
1299            (VirtualKeyCode::Return, &[][..], None),
1300            (VirtualKeyCode::PageDown, &[][..], None),
1301        ] {
1302            let action =
1303                determine_keyboard_default_action(&kbd(key, mods), focus, &layouts, false).action;
1304            assert_eq!(
1305                default_action_to_focus_target(&action),
1306                expected,
1307                "{key:?} + {mods:?} round-trips to the wrong FocusTarget"
1308            );
1309        }
1310    }
1311
1312    #[test]
1313    fn a_prevented_key_press_never_reaches_the_focus_manager() {
1314        let layouts = fixture();
1315        for key in ALL_KEYS {
1316            for mods in MOD_SETS {
1317                let action = determine_keyboard_default_action(
1318                    &kbd(*key, mods),
1319                    Some(button(&layouts)),
1320                    &layouts,
1321                    true,
1322                )
1323                .action;
1324                assert_eq!(
1325                    default_action_to_focus_target(&action),
1326                    None,
1327                    "{key:?} was prevented, so focus must not move"
1328                );
1329            }
1330        }
1331    }
1332}