1use 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#[allow(clippy::too_many_lines)] #[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 {
58 return DefaultActionResult::prevented();
59 }
60
61 let Some(current_key) = keyboard_state.current_virtual_keycode.into_option() else {
63 return DefaultActionResult::default();
64 };
65
66 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 let action = match current_key {
73 VirtualKeyCode::Tab => {
75 if ctrl_down || alt_down {
76 DefaultAction::None
78 } else if shift_down {
79 DefaultAction::FocusPrevious
80 } else {
81 DefaultAction::FocusNext
82 }
83 }
84
85 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 DefaultAction::None
95 })
96 }
97
98 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 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 VirtualKeyCode::Escape => {
123 if focused_node.is_some() {
124 DefaultAction::ClearFocus
125 } else {
126 DefaultAction::None
128 }
129 }
130
131 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 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 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 VirtualKeyCode::Home => {
176 if ctrl_down {
177 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 DefaultAction::FocusLast
190 } else {
191 DefaultAction::ScrollFocusedContainer {
192 direction: ScrollDirection::Down,
193 amount: ScrollAmount::Document,
194 }
195 }
196 }
197
198 _ => DefaultAction::None,
200 };
201
202 DefaultActionResult::new(action)
203}
204
205fn 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
218fn 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 node.get_callbacks()
235 .iter()
236 .any(|cb| matches!(cb.event, EventFilter::Focus(FocusEventFilter::TextInput)))
237}
238
239#[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)] 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)] fn test_shift_tab_focus_previous() {
279 let mut keyboard_state = KeyboardState::default();
280 keyboard_state.current_virtual_keycode = Some(VirtualKeyCode::Tab).into();
281 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)] 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)] 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, );
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)] mod 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 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 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 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 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 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 fn out_of_bounds_node() -> DomNodeId {
501 dom_node(9999)
502 }
503
504 fn null_node() -> DomNodeId {
506 DomNodeId {
507 dom: DomId::ROOT_ID,
508 node: NodeHierarchyItemId::NONE,
509 }
510 }
511
512 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 #[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 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 #[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 &[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 #[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 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 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 #[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 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 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 #[test]
869 fn escape_clears_focus_only_when_something_is_focused() {
870 let layouts = fixture();
871 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 #[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 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 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 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 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 #[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 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 #[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 assert!(!is_element_activatable(&button(&layouts), &empty));
1116 assert!(!is_element_activatable(&dom_node(0), &empty));
1117 }
1118
1119 #[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 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 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 #[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 (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}