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
/// Determine the default action for a keyboard event based on the
48
/// current key, focused element, and whether `prevent_default()` was called.
49
4
pub fn determine_keyboard_default_action(
50
4
    keyboard_state: &KeyboardState,
51
4
    focused_node: Option<DomNodeId>,
52
4
    layout_results: &BTreeMap<DomId, DomLayoutResult>,
53
4
    prevented: bool,
54
4
) -> DefaultActionResult {
55
    // If prevented, return early with no action
56
4
    if prevented {
57
1
        return DefaultActionResult::prevented();
58
3
    }
59

            
60
    // Get the current key (if any)
61
3
    let current_key = match keyboard_state.current_virtual_keycode.into_option() {
62
3
        Some(key) => key,
63
        None => return DefaultActionResult::default(),
64
    };
65

            
66
    // Check modifier state
67
3
    let shift_down = keyboard_state.shift_down();
68
3
    let ctrl_down = keyboard_state.ctrl_down();
69
3
    let alt_down = keyboard_state.alt_down();
70

            
71
    // Determine action based on key
72
3
    let action = match current_key {
73
        // Tab navigation
74
        VirtualKeyCode::Tab => {
75
2
            if ctrl_down || alt_down {
76
                // Ctrl+Tab / Alt+Tab are typically handled by OS
77
                DefaultAction::None
78
2
            } else if shift_down {
79
1
                DefaultAction::FocusPrevious
80
            } else {
81
1
                DefaultAction::FocusNext
82
            }
83
        }
84

            
85
        // Activation (Enter key)
86
        VirtualKeyCode::Return | VirtualKeyCode::NumpadEnter => {
87
            if let Some(ref focus) = focused_node {
88
                if is_element_activatable(focus, layout_results) {
89
                    DefaultAction::ActivateFocusedElement {
90
                        target: focus.clone(),
91
                    }
92
                } else {
93
                    // Enter on non-activatable element - might submit form
94
                    // For now, no action (form handling could be added later)
95
                    DefaultAction::None
96
                }
97
            } else {
98
                DefaultAction::None
99
            }
100
        }
101

            
102
        // Activation (Space key)
103
        VirtualKeyCode::Space => {
104
            if let Some(ref focus) = focused_node {
105
                // Space only activates if the focused element is activatable
106
                // and we're not in a text input
107
                if is_element_activatable(focus, layout_results)
108
                    && !is_text_input(focus, layout_results)
109
                {
110
                    DefaultAction::ActivateFocusedElement {
111
                        target: focus.clone(),
112
                    }
113
                } else {
114
                    // Space in text input should insert space (handled by text input system)
115
                    DefaultAction::None
116
                }
117
            } else {
118
                DefaultAction::None
119
            }
120
        }
121

            
122
        // Escape - clear focus
123
        VirtualKeyCode::Escape => {
124
1
            if focused_node.is_some() {
125
1
                DefaultAction::ClearFocus
126
            } else {
127
                // Could close modal/dialog here if any is open
128
                DefaultAction::None
129
            }
130
        }
131

            
132
        // Arrow keys - scroll or navigate
133
        VirtualKeyCode::Up | VirtualKeyCode::Down | VirtualKeyCode::Left | VirtualKeyCode::Right => {
134
            let direction = match current_key {
135
                VirtualKeyCode::Up => ScrollDirection::Up,
136
                VirtualKeyCode::Down => ScrollDirection::Down,
137
                VirtualKeyCode::Left => ScrollDirection::Left,
138
                _ => ScrollDirection::Right,
139
            };
140
            if let Some(ref focus) = focused_node {
141
                if !is_text_input(focus, layout_results) {
142
                    DefaultAction::ScrollFocusedContainer {
143
                        direction,
144
                        amount: ScrollAmount::Line,
145
                    }
146
                } else {
147
                    DefaultAction::None
148
                }
149
            } else {
150
                DefaultAction::None
151
            }
152
        }
153

            
154
        // Page Up/Down
155
        VirtualKeyCode::PageUp => {
156
            DefaultAction::ScrollFocusedContainer {
157
                direction: ScrollDirection::Up,
158
                amount: ScrollAmount::Page,
159
            }
160
        }
161
        VirtualKeyCode::PageDown => {
162
            DefaultAction::ScrollFocusedContainer {
163
                direction: ScrollDirection::Down,
164
                amount: ScrollAmount::Page,
165
            }
166
        }
167

            
168
        // Home/End
169
        VirtualKeyCode::Home => {
170
            if ctrl_down {
171
                // Ctrl+Home - go to start of document
172
                DefaultAction::FocusFirst
173
            } else {
174
                DefaultAction::ScrollFocusedContainer {
175
                    direction: ScrollDirection::Up,
176
                    amount: ScrollAmount::Document,
177
                }
178
            }
179
        }
180
        VirtualKeyCode::End => {
181
            if ctrl_down {
182
                // Ctrl+End - go to end of document
183
                DefaultAction::FocusLast
184
            } else {
185
                DefaultAction::ScrollFocusedContainer {
186
                    direction: ScrollDirection::Down,
187
                    amount: ScrollAmount::Document,
188
                }
189
            }
190
        }
191

            
192
        // All other keys - no default action
193
        _ => DefaultAction::None,
194
    };
195

            
196
3
    DefaultActionResult::new(action)
197
4
}
198

            
199
/// Check if an element is activatable (can receive synthetic click from Enter/Space).
200
fn is_element_activatable(node_id: &DomNodeId, layout_results: &BTreeMap<DomId, DomLayoutResult>) -> bool {
201
    let Some(layout) = layout_results.get(&node_id.dom) else {
202
        return false;
203
    };
204
    let Some(internal_id) = node_id.node.into_crate_internal() else {
205
        return false;
206
    };
207
    layout.styled_dom.node_data.as_container()
208
        .get(internal_id)
209
        .map(|node| node.is_activatable())
210
        .unwrap_or(false)
211
}
212

            
213
/// Check if an element is a text input (where Space should insert text, not activate).
214
fn is_text_input(node_id: &DomNodeId, layout_results: &BTreeMap<DomId, DomLayoutResult>) -> bool {
215
    let Some(layout) = layout_results.get(&node_id.dom) else {
216
        return false;
217
    };
218
    let Some(internal_id) = node_id.node.into_crate_internal() else {
219
        return false;
220
    };
221
    let node_data = layout.styled_dom.node_data.as_container();
222
    let Some(node) = node_data.get(internal_id) else {
223
        return false;
224
    };
225

            
226
    // Check if this node has a TextInput callback (FocusEventFilter::TextInput)
227
    // which indicates it's a text input field
228
    use azul_core::events::{EventFilter, FocusEventFilter};
229
    node.get_callbacks()
230
        .iter()
231
        .any(|cb| matches!(cb.event, EventFilter::Focus(FocusEventFilter::TextInput)))
232
}
233

            
234
/// Convert a DefaultAction to a FocusTarget for the focus manager.
235
///
236
/// This bridges the gap between the abstract DefaultAction and the
237
/// concrete FocusTarget that the FocusManager understands.
238
pub fn default_action_to_focus_target(action: &DefaultAction) -> Option<FocusTarget> {
239
    match action {
240
        DefaultAction::FocusNext => Some(FocusTarget::Next),
241
        DefaultAction::FocusPrevious => Some(FocusTarget::Previous),
242
        DefaultAction::FocusFirst => Some(FocusTarget::First),
243
        DefaultAction::FocusLast => Some(FocusTarget::Last),
244
        DefaultAction::ClearFocus => Some(FocusTarget::NoFocus),
245
        _ => None,
246
    }
247
}
248

            
249
#[cfg(test)]
250
mod tests {
251
    use super::*;
252
    use azul_core::styled_dom::NodeHierarchyItemId;
253

            
254
    #[test]
255
1
    fn test_tab_focus_next() {
256
1
        let mut keyboard_state = KeyboardState::default();
257
1
        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
258
        
259
1
        let result = determine_keyboard_default_action(
260
1
            &keyboard_state,
261
1
            None,
262
1
            &BTreeMap::new(),
263
            false,
264
        );
265
        
266
1
        assert!(matches!(result.action, DefaultAction::FocusNext));
267
1
        assert!(!result.prevented);
268
1
    }
269

            
270
    #[test]
271
1
    fn test_shift_tab_focus_previous() {
272
1
        let mut keyboard_state = KeyboardState::default();
273
1
        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
274
        // Add LShift to pressed keys to simulate Shift being held
275
1
        keyboard_state.pressed_virtual_keycodes = vec![VirtualKeyCode::LShift, VirtualKeyCode::Tab].into();
276
        
277
1
        let result = determine_keyboard_default_action(
278
1
            &keyboard_state,
279
1
            None,
280
1
            &BTreeMap::new(),
281
            false,
282
        );
283
        
284
1
        assert!(matches!(result.action, DefaultAction::FocusPrevious));
285
1
    }
286

            
287
    #[test]
288
1
    fn test_escape_clears_focus() {
289
1
        let mut keyboard_state = KeyboardState::default();
290
1
        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Escape).into();
291
        
292
1
        let focused = Some(DomNodeId {
293
1
            dom: DomId { inner: 0 },
294
1
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(1))),
295
1
        });
296
        
297
1
        let result = determine_keyboard_default_action(
298
1
            &keyboard_state,
299
1
            focused,
300
1
            &BTreeMap::new(),
301
            false,
302
        );
303
        
304
1
        assert!(matches!(result.action, DefaultAction::ClearFocus));
305
1
    }
306

            
307
    #[test]
308
1
    fn test_prevented_returns_no_action() {
309
1
        let mut keyboard_state = KeyboardState::default();
310
1
        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
311
        
312
1
        let result = determine_keyboard_default_action(
313
1
            &keyboard_state,
314
1
            None,
315
1
            &BTreeMap::new(),
316
            true, // prevented!
317
        );
318
        
319
1
        assert!(result.prevented);
320
1
        assert!(matches!(result.action, DefaultAction::None));
321
1
    }
322
}