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, EventType, ScrollAmount, ScrollDirection, SyntheticEvent},
42    styled_dom::NodeHierarchyItemId,
43    window::{KeyboardState, VirtualKeyCode},
44};
45use crate::window::DomLayoutResult;
46use std::collections::BTreeMap;
47
48/// Determine the default action for a keyboard event.
49///
50/// This function examines the keyboard state and focused element to determine
51/// what default action (if any) should be performed.
52///
53/// # Arguments
54///
55/// * `keyboard_state` - Current keyboard state with pressed key
56/// * `focused_node` - Currently focused node (if any)
57/// * `layout_results` - DOM layout information for querying node properties
58/// * `prevented` - Whether `prevent_default()` was called during event dispatch
59///
60/// # Returns
61///
62/// A `DefaultActionResult` indicating what action to perform, or `None` if prevented.
63pub fn determine_keyboard_default_action(
64    keyboard_state: &KeyboardState,
65    focused_node: Option<DomNodeId>,
66    layout_results: &BTreeMap<DomId, DomLayoutResult>,
67    prevented: bool,
68) -> DefaultActionResult {
69    // If prevented, return early with no action
70    if prevented {
71        return DefaultActionResult::prevented();
72    }
73
74    // Get the current key (if any)
75    let current_key = match keyboard_state.current_virtual_keycode.into_option() {
76        Some(key) => key,
77        None => return DefaultActionResult::default(),
78    };
79
80    // Check modifier state
81    let shift_down = keyboard_state.shift_down();
82    let ctrl_down = keyboard_state.ctrl_down();
83    let alt_down = keyboard_state.alt_down();
84
85    // Determine action based on key
86    let action = match current_key {
87        // Tab navigation
88        VirtualKeyCode::Tab => {
89            if ctrl_down || alt_down {
90                // Ctrl+Tab / Alt+Tab are typically handled by OS
91                DefaultAction::None
92            } else if shift_down {
93                DefaultAction::FocusPrevious
94            } else {
95                DefaultAction::FocusNext
96            }
97        }
98
99        // Activation (Enter key)
100        VirtualKeyCode::Return | VirtualKeyCode::NumpadEnter => {
101            if let Some(ref focus) = focused_node {
102                if is_element_activatable(focus, layout_results) {
103                    DefaultAction::ActivateFocusedElement {
104                        target: focus.clone(),
105                    }
106                } else {
107                    // Enter on non-activatable element - might submit form
108                    // For now, no action (form handling could be added later)
109                    DefaultAction::None
110                }
111            } else {
112                DefaultAction::None
113            }
114        }
115
116        // Activation (Space key)
117        VirtualKeyCode::Space => {
118            if let Some(ref focus) = focused_node {
119                // Space only activates if the focused element is activatable
120                // and we're not in a text input
121                if is_element_activatable(focus, layout_results)
122                    && !is_text_input(focus, layout_results)
123                {
124                    DefaultAction::ActivateFocusedElement {
125                        target: focus.clone(),
126                    }
127                } else {
128                    // Space in text input should insert space (handled by text input system)
129                    DefaultAction::None
130                }
131            } else {
132                DefaultAction::None
133            }
134        }
135
136        // Escape - clear focus
137        VirtualKeyCode::Escape => {
138            if focused_node.is_some() {
139                DefaultAction::ClearFocus
140            } else {
141                // Could close modal/dialog here if any is open
142                DefaultAction::None
143            }
144        }
145
146        // Arrow keys - scroll or navigate
147        VirtualKeyCode::Up => {
148            if focused_node.is_some() && !is_text_input(&focused_node.as_ref().unwrap(), layout_results) {
149                DefaultAction::ScrollFocusedContainer {
150                    direction: ScrollDirection::Up,
151                    amount: ScrollAmount::Line,
152                }
153            } else {
154                DefaultAction::None
155            }
156        }
157        VirtualKeyCode::Down => {
158            if focused_node.is_some() && !is_text_input(&focused_node.as_ref().unwrap(), layout_results) {
159                DefaultAction::ScrollFocusedContainer {
160                    direction: ScrollDirection::Down,
161                    amount: ScrollAmount::Line,
162                }
163            } else {
164                DefaultAction::None
165            }
166        }
167        VirtualKeyCode::Left => {
168            if focused_node.is_some() && !is_text_input(&focused_node.as_ref().unwrap(), layout_results) {
169                DefaultAction::ScrollFocusedContainer {
170                    direction: ScrollDirection::Left,
171                    amount: ScrollAmount::Line,
172                }
173            } else {
174                DefaultAction::None
175            }
176        }
177        VirtualKeyCode::Right => {
178            if focused_node.is_some() && !is_text_input(&focused_node.as_ref().unwrap(), layout_results) {
179                DefaultAction::ScrollFocusedContainer {
180                    direction: ScrollDirection::Right,
181                    amount: ScrollAmount::Line,
182                }
183            } else {
184                DefaultAction::None
185            }
186        }
187
188        // Page Up/Down
189        VirtualKeyCode::PageUp => {
190            DefaultAction::ScrollFocusedContainer {
191                direction: ScrollDirection::Up,
192                amount: ScrollAmount::Page,
193            }
194        }
195        VirtualKeyCode::PageDown => {
196            DefaultAction::ScrollFocusedContainer {
197                direction: ScrollDirection::Down,
198                amount: ScrollAmount::Page,
199            }
200        }
201
202        // Home/End
203        VirtualKeyCode::Home => {
204            if ctrl_down {
205                // Ctrl+Home - go to start of document
206                DefaultAction::FocusFirst
207            } else {
208                DefaultAction::ScrollFocusedContainer {
209                    direction: ScrollDirection::Up,
210                    amount: ScrollAmount::Document,
211                }
212            }
213        }
214        VirtualKeyCode::End => {
215            if ctrl_down {
216                // Ctrl+End - go to end of document
217                DefaultAction::FocusLast
218            } else {
219                DefaultAction::ScrollFocusedContainer {
220                    direction: ScrollDirection::Down,
221                    amount: ScrollAmount::Document,
222                }
223            }
224        }
225
226        // All other keys - no default action
227        _ => DefaultAction::None,
228    };
229
230    DefaultActionResult::new(action)
231}
232
233/// Check if an element is activatable (can receive synthetic click from Enter/Space).
234fn is_element_activatable(node_id: &DomNodeId, layout_results: &BTreeMap<DomId, DomLayoutResult>) -> bool {
235    let Some(layout) = layout_results.get(&node_id.dom) else {
236        return false;
237    };
238    let Some(internal_id) = node_id.node.into_crate_internal() else {
239        return false;
240    };
241    layout.styled_dom.node_data.as_container()
242        .get(internal_id)
243        .map(|node| node.is_activatable())
244        .unwrap_or(false)
245}
246
247/// Check if an element is a text input (where Space should insert text, not activate).
248fn is_text_input(node_id: &DomNodeId, layout_results: &BTreeMap<DomId, DomLayoutResult>) -> bool {
249    let Some(layout) = layout_results.get(&node_id.dom) else {
250        return false;
251    };
252    let Some(internal_id) = node_id.node.into_crate_internal() else {
253        return false;
254    };
255    let node_data = layout.styled_dom.node_data.as_container();
256    let Some(node) = node_data.get(internal_id) else {
257        return false;
258    };
259
260    // Check if this node has a TextInput callback (FocusEventFilter::TextInput)
261    // which indicates it's a text input field
262    use azul_core::events::{EventFilter, FocusEventFilter};
263    node.get_callbacks()
264        .iter()
265        .any(|cb| matches!(cb.event, EventFilter::Focus(FocusEventFilter::TextInput)))
266}
267
268/// Convert a DefaultAction to a FocusTarget for the focus manager.
269///
270/// This bridges the gap between the abstract DefaultAction and the
271/// concrete FocusTarget that the FocusManager understands.
272pub fn default_action_to_focus_target(action: &DefaultAction) -> Option<FocusTarget> {
273    match action {
274        DefaultAction::FocusNext => Some(FocusTarget::Next),
275        DefaultAction::FocusPrevious => Some(FocusTarget::Previous),
276        DefaultAction::FocusFirst => Some(FocusTarget::First),
277        DefaultAction::FocusLast => Some(FocusTarget::Last),
278        DefaultAction::ClearFocus => Some(FocusTarget::NoFocus),
279        _ => None,
280    }
281}
282
283/// Create a synthetic click event for element activation.
284///
285/// When Enter or Space is pressed on an activatable element, we generate
286/// a synthetic click event that will be dispatched through the normal
287/// event system.
288pub fn create_activation_click_event(
289    target: &DomNodeId,
290    timestamp: azul_core::task::Instant,
291) -> SyntheticEvent {
292    use azul_core::events::{EventData, EventSource};
293    
294    SyntheticEvent::new(
295        EventType::Click,
296        EventSource::Synthetic,
297        target.clone(),
298        timestamp,
299        EventData::None,
300    )
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_tab_focus_next() {
309        let mut keyboard_state = KeyboardState::default();
310        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
311        
312        let result = determine_keyboard_default_action(
313            &keyboard_state,
314            None,
315            &BTreeMap::new(),
316            false,
317        );
318        
319        assert!(matches!(result.action, DefaultAction::FocusNext));
320        assert!(!result.prevented);
321    }
322
323    #[test]
324    fn test_shift_tab_focus_previous() {
325        let mut keyboard_state = KeyboardState::default();
326        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
327        // Add LShift to pressed keys to simulate Shift being held
328        keyboard_state.pressed_virtual_keycodes = vec![VirtualKeyCode::LShift, VirtualKeyCode::Tab].into();
329        
330        let result = determine_keyboard_default_action(
331            &keyboard_state,
332            None,
333            &BTreeMap::new(),
334            false,
335        );
336        
337        assert!(matches!(result.action, DefaultAction::FocusPrevious));
338    }
339
340    #[test]
341    fn test_escape_clears_focus() {
342        let mut keyboard_state = KeyboardState::default();
343        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Escape).into();
344        
345        let focused = Some(DomNodeId {
346            dom: DomId { inner: 0 },
347            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(1))),
348        });
349        
350        let result = determine_keyboard_default_action(
351            &keyboard_state,
352            focused,
353            &BTreeMap::new(),
354            false,
355        );
356        
357        assert!(matches!(result.action, DefaultAction::ClearFocus));
358    }
359
360    #[test]
361    fn test_prevented_returns_no_action() {
362        let mut keyboard_state = KeyboardState::default();
363        keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
364        
365        let result = determine_keyboard_default_action(
366            &keyboard_state,
367            None,
368            &BTreeMap::new(),
369            true, // prevented!
370        );
371        
372        assert!(result.prevented);
373        assert!(matches!(result.action, DefaultAction::None));
374    }
375}